R1 foundation - Phase 1 live build
This commit is contained in:
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
||||
.env
|
||||
.git
|
||||
__pycache__
|
||||
*.pyc
|
||||
.pytest_cache
|
||||
docker-compose.yml
|
||||
.dockerignore
|
||||
.gitignore
|
||||
10
.env.example
Normal file
10
.env.example
Normal file
@@ -0,0 +1,10 @@
|
||||
# Life OS Environment Configuration
|
||||
# Copy to .env and fill in values. NEVER commit .env to git.
|
||||
|
||||
DATABASE_URL=postgresql+asyncpg://postgres:YOUR_PASSWORD@lifeos-db:5432/lifeos_dev
|
||||
FILE_STORAGE_PATH=/opt/lifeos/files/dev
|
||||
ENVIRONMENT=development
|
||||
|
||||
# Phase 2
|
||||
# MCP_API_KEY=your-secret-key
|
||||
# ANTHROPIC_API_KEY=your-key
|
||||
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.venv/
|
||||
venv/
|
||||
22
Dockerfile
Normal file
22
Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Python dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application
|
||||
COPY . .
|
||||
|
||||
# Create file storage directory
|
||||
RUN mkdir -p /opt/lifeos/files
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
||||
0
core/__init__.py
Normal file
0
core/__init__.py
Normal file
213
core/base_repository.py
Normal file
213
core/base_repository.py
Normal file
@@ -0,0 +1,213 @@
|
||||
from __future__ import annotations
|
||||
"""
|
||||
BaseRepository: generic CRUD operations for all entities.
|
||||
Uses raw SQL via SQLAlchemy text() - no ORM models needed.
|
||||
Every method automatically filters is_deleted=false unless specified.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
class BaseRepository:
|
||||
def __init__(self, table: str, db: AsyncSession):
|
||||
self.table = table
|
||||
self.db = db
|
||||
|
||||
async def list(
|
||||
self,
|
||||
filters: dict | None = None,
|
||||
sort: str = "sort_order",
|
||||
sort_dir: str = "ASC",
|
||||
page: int = 1,
|
||||
per_page: int = 50,
|
||||
include_deleted: bool = False,
|
||||
) -> list[dict]:
|
||||
"""List rows with optional filtering, sorting, pagination."""
|
||||
where_clauses = []
|
||||
params: dict[str, Any] = {}
|
||||
|
||||
if not include_deleted:
|
||||
where_clauses.append("is_deleted = false")
|
||||
|
||||
if filters:
|
||||
for i, (key, value) in enumerate(filters.items()):
|
||||
if value is None:
|
||||
where_clauses.append(f"{key} IS NULL")
|
||||
elif value == "__notnull__":
|
||||
where_clauses.append(f"{key} IS NOT NULL")
|
||||
else:
|
||||
param_name = f"f_{i}"
|
||||
where_clauses.append(f"{key} = :{param_name}")
|
||||
params[param_name] = value
|
||||
|
||||
where_sql = " AND ".join(where_clauses) if where_clauses else "1=1"
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
query = text(f"""
|
||||
SELECT * FROM {self.table}
|
||||
WHERE {where_sql}
|
||||
ORDER BY {sort} {sort_dir}
|
||||
LIMIT :limit OFFSET :offset
|
||||
""")
|
||||
params["limit"] = per_page
|
||||
params["offset"] = offset
|
||||
|
||||
result = await self.db.execute(query, params)
|
||||
return [dict(row._mapping) for row in result]
|
||||
|
||||
async def count(
|
||||
self,
|
||||
filters: dict | None = None,
|
||||
include_deleted: bool = False,
|
||||
) -> int:
|
||||
"""Count rows matching filters."""
|
||||
where_clauses = []
|
||||
params: dict[str, Any] = {}
|
||||
|
||||
if not include_deleted:
|
||||
where_clauses.append("is_deleted = false")
|
||||
|
||||
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"f_{i}"
|
||||
where_clauses.append(f"{key} = :{param_name}")
|
||||
params[param_name] = value
|
||||
|
||||
where_sql = " AND ".join(where_clauses) if where_clauses else "1=1"
|
||||
query = text(f"SELECT count(*) FROM {self.table} WHERE {where_sql}")
|
||||
result = await self.db.execute(query, params)
|
||||
return result.scalar() or 0
|
||||
|
||||
async def get(self, id: UUID | str) -> dict | None:
|
||||
"""Get a single row by ID."""
|
||||
query = text(f"SELECT * FROM {self.table} WHERE id = :id")
|
||||
result = await self.db.execute(query, {"id": str(id)})
|
||||
row = result.first()
|
||||
return dict(row._mapping) if row else None
|
||||
|
||||
async def create(self, data: dict) -> dict:
|
||||
"""Insert a new row. Auto-sets created_at, updated_at, is_deleted."""
|
||||
data = {k: v for k, v in data.items() if v is not None or k in ("description", "notes", "body")}
|
||||
data.setdefault("is_deleted", False)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
if "created_at" not in data:
|
||||
data["created_at"] = now
|
||||
if "updated_at" not in data:
|
||||
data["updated_at"] = now
|
||||
|
||||
columns = ", ".join(data.keys())
|
||||
placeholders = ", ".join(f":{k}" for k in data.keys())
|
||||
|
||||
query = text(f"""
|
||||
INSERT INTO {self.table} ({columns})
|
||||
VALUES ({placeholders})
|
||||
RETURNING *
|
||||
""")
|
||||
result = await self.db.execute(query, data)
|
||||
row = result.first()
|
||||
return dict(row._mapping) if row else data
|
||||
|
||||
async def update(self, id: UUID | str, data: dict) -> dict | None:
|
||||
"""Update a row by ID. Auto-sets updated_at."""
|
||||
data["updated_at"] = datetime.now(timezone.utc)
|
||||
|
||||
# Remove None values except for fields that should be nullable
|
||||
nullable_fields = {
|
||||
"description", "notes", "body", "area_id", "project_id",
|
||||
"parent_id", "release_id", "due_date", "deadline", "tags",
|
||||
"context", "folder_id", "meeting_id", "completed_at",
|
||||
"waiting_for_contact_id", "waiting_since", "color",
|
||||
}
|
||||
clean_data = {}
|
||||
for k, v in data.items():
|
||||
if v is not None or k in nullable_fields:
|
||||
clean_data[k] = v
|
||||
|
||||
if not clean_data:
|
||||
return await self.get(id)
|
||||
|
||||
set_clauses = ", ".join(f"{k} = :{k}" for k in clean_data.keys())
|
||||
clean_data["id"] = str(id)
|
||||
|
||||
query = text(f"""
|
||||
UPDATE {self.table}
|
||||
SET {set_clauses}
|
||||
WHERE id = :id
|
||||
RETURNING *
|
||||
""")
|
||||
result = await self.db.execute(query, clean_data)
|
||||
row = result.first()
|
||||
return dict(row._mapping) if row else None
|
||||
|
||||
async def soft_delete(self, id: UUID | str) -> bool:
|
||||
"""Soft delete: set is_deleted=true, deleted_at=now()."""
|
||||
query = text(f"""
|
||||
UPDATE {self.table}
|
||||
SET is_deleted = true, deleted_at = :now, updated_at = :now
|
||||
WHERE id = :id AND is_deleted = false
|
||||
RETURNING id
|
||||
""")
|
||||
now = datetime.now(timezone.utc)
|
||||
result = await self.db.execute(query, {"id": str(id), "now": now})
|
||||
return result.first() is not None
|
||||
|
||||
async def restore(self, id: UUID | str) -> bool:
|
||||
"""Restore a soft-deleted row."""
|
||||
query = text(f"""
|
||||
UPDATE {self.table}
|
||||
SET is_deleted = false, deleted_at = NULL, updated_at = :now
|
||||
WHERE id = :id AND is_deleted = true
|
||||
RETURNING id
|
||||
""")
|
||||
now = datetime.now(timezone.utc)
|
||||
result = await self.db.execute(query, {"id": str(id), "now": now})
|
||||
return result.first() is not None
|
||||
|
||||
async def permanent_delete(self, id: UUID | str) -> bool:
|
||||
"""Hard delete. Admin only."""
|
||||
query = text(f"DELETE FROM {self.table} WHERE id = :id RETURNING id")
|
||||
result = await self.db.execute(query, {"id": str(id)})
|
||||
return result.first() is not None
|
||||
|
||||
async def bulk_soft_delete(self, ids: list[str]) -> int:
|
||||
"""Soft delete multiple rows."""
|
||||
if not ids:
|
||||
return 0
|
||||
now = datetime.now(timezone.utc)
|
||||
placeholders = ", ".join(f":id_{i}" for i in range(len(ids)))
|
||||
params = {f"id_{i}": str(id) for i, id in enumerate(ids)}
|
||||
params["now"] = now
|
||||
|
||||
query = text(f"""
|
||||
UPDATE {self.table}
|
||||
SET is_deleted = true, deleted_at = :now, updated_at = :now
|
||||
WHERE id IN ({placeholders}) AND is_deleted = false
|
||||
""")
|
||||
result = await self.db.execute(query, params)
|
||||
return result.rowcount
|
||||
|
||||
async def list_deleted(self) -> list[dict]:
|
||||
"""List all soft-deleted rows. Used by Admin > Trash."""
|
||||
query = text(f"""
|
||||
SELECT * FROM {self.table}
|
||||
WHERE is_deleted = true
|
||||
ORDER BY deleted_at DESC
|
||||
""")
|
||||
result = await self.db.execute(query)
|
||||
return [dict(row._mapping) for row in result]
|
||||
|
||||
async def reorder(self, id_order: list[str]) -> None:
|
||||
"""Update sort_order based on position in list."""
|
||||
for i, id in enumerate(id_order):
|
||||
await self.db.execute(
|
||||
text(f"UPDATE {self.table} SET sort_order = :order WHERE id = :id"),
|
||||
{"order": (i + 1) * 10, "id": str(id)}
|
||||
)
|
||||
47
core/database.py
Normal file
47
core/database.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""
|
||||
Database connection and session management.
|
||||
Async SQLAlchemy 2.0 with asyncpg driver.
|
||||
"""
|
||||
|
||||
import os
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy import text
|
||||
|
||||
DATABASE_URL = os.getenv(
|
||||
"DATABASE_URL",
|
||||
"postgresql+asyncpg://postgres:postgres@lifeos-db:5432/lifeos_dev"
|
||||
)
|
||||
|
||||
engine = create_async_engine(
|
||||
DATABASE_URL,
|
||||
echo=os.getenv("ENVIRONMENT") == "development",
|
||||
pool_size=5,
|
||||
max_overflow=10,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
|
||||
async_session_factory = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
|
||||
async def get_db():
|
||||
"""FastAPI dependency: yields an async database session."""
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def check_db():
|
||||
"""Health check: verify database connectivity."""
|
||||
async with async_session_factory() as session:
|
||||
result = await session.execute(text("SELECT 1"))
|
||||
return result.scalar() == 1
|
||||
72
core/sidebar.py
Normal file
72
core/sidebar.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""
|
||||
Sidebar navigation data builder.
|
||||
Loads domains > areas > projects hierarchy for the sidebar tree.
|
||||
"""
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
async def get_sidebar_data(db: AsyncSession) -> dict:
|
||||
"""Build full sidebar navigation data."""
|
||||
|
||||
# Domains
|
||||
result = await db.execute(text("""
|
||||
SELECT id, name, color FROM domains
|
||||
WHERE is_deleted = false ORDER BY sort_order, name
|
||||
"""))
|
||||
domains = [dict(r._mapping) for r in result]
|
||||
|
||||
# Areas grouped by domain
|
||||
result = await db.execute(text("""
|
||||
SELECT id, domain_id, name FROM areas
|
||||
WHERE is_deleted = false ORDER BY sort_order, name
|
||||
"""))
|
||||
areas = [dict(r._mapping) for r in result]
|
||||
|
||||
# Projects grouped by domain/area
|
||||
result = await db.execute(text("""
|
||||
SELECT id, domain_id, area_id, name, status FROM projects
|
||||
WHERE is_deleted = false AND status != 'archived'
|
||||
ORDER BY sort_order, name
|
||||
"""))
|
||||
projects = [dict(r._mapping) for r in result]
|
||||
|
||||
# Counts for badges
|
||||
result = await db.execute(text("""
|
||||
SELECT count(*) FROM capture WHERE is_deleted = false AND processed = false
|
||||
"""))
|
||||
capture_count = result.scalar() or 0
|
||||
|
||||
result = await db.execute(text("""
|
||||
SELECT count(*) FROM daily_focus
|
||||
WHERE is_deleted = false AND focus_date = CURRENT_DATE AND completed = false
|
||||
"""))
|
||||
focus_count = result.scalar() or 0
|
||||
|
||||
# Build tree structure
|
||||
domain_tree = []
|
||||
for d in domains:
|
||||
d_areas = [a for a in areas if str(a["domain_id"]) == str(d["id"])]
|
||||
d_projects = [p for p in projects if str(p["domain_id"]) == str(d["id"])]
|
||||
|
||||
# Projects under areas
|
||||
for a in d_areas:
|
||||
a["projects"] = [p for p in d_projects if str(p.get("area_id", "")) == str(a["id"])]
|
||||
|
||||
# Projects directly under domain (no area)
|
||||
standalone_projects = [p for p in d_projects if p.get("area_id") is None]
|
||||
|
||||
domain_tree.append({
|
||||
"id": d["id"],
|
||||
"name": d["name"],
|
||||
"color": d.get("color", "#4F6EF7"),
|
||||
"areas": d_areas,
|
||||
"standalone_projects": standalone_projects,
|
||||
})
|
||||
|
||||
return {
|
||||
"domain_tree": domain_tree,
|
||||
"capture_count": capture_count,
|
||||
"focus_count": focus_count,
|
||||
}
|
||||
47
docker-compose.yml
Normal file
47
docker-compose.yml
Normal file
@@ -0,0 +1,47 @@
|
||||
version: '3.9'
|
||||
|
||||
services:
|
||||
lifeos-prod:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: lifeos-prod
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://postgres:${DB_PASSWORD}@lifeos-db:5432/lifeos_prod
|
||||
FILE_STORAGE_PATH: /opt/lifeos/files/prod
|
||||
ENVIRONMENT: production
|
||||
command: uvicorn main:app --host 0.0.0.0 --port 8002 --workers 1
|
||||
ports:
|
||||
- "8002:8002"
|
||||
volumes:
|
||||
- /opt/lifeos/prod/files:/opt/lifeos/files/prod
|
||||
networks:
|
||||
- lifeos_network
|
||||
depends_on:
|
||||
- lifeos-db
|
||||
|
||||
lifeos-dev:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: lifeos-dev
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://postgres:${DB_PASSWORD}@lifeos-db:5432/lifeos_dev
|
||||
FILE_STORAGE_PATH: /opt/lifeos/files/dev
|
||||
ENVIRONMENT: development
|
||||
command: uvicorn main:app --host 0.0.0.0 --port 8003 --workers 1 --reload
|
||||
ports:
|
||||
- "8003:8003"
|
||||
volumes:
|
||||
- /opt/lifeos/dev/files:/opt/lifeos/files/dev
|
||||
- .:/app # hot reload in dev
|
||||
networks:
|
||||
- lifeos_network
|
||||
depends_on:
|
||||
- lifeos-db
|
||||
|
||||
networks:
|
||||
lifeos_network:
|
||||
external: true
|
||||
169
main.py
Normal file
169
main.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""
|
||||
Life OS - Main Application
|
||||
FastAPI server-rendered monolith with async PostgreSQL.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from contextlib import asynccontextmanager
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from core.database import check_db
|
||||
from core.sidebar import get_sidebar_data
|
||||
from core.database import get_db
|
||||
|
||||
# Import routers
|
||||
from routers import (
|
||||
domains as domains_router,
|
||||
areas as areas_router,
|
||||
projects as projects_router,
|
||||
tasks as tasks_router,
|
||||
notes as notes_router,
|
||||
links as links_router,
|
||||
focus as focus_router,
|
||||
capture as capture_router,
|
||||
contacts as contacts_router,
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Startup/shutdown events."""
|
||||
# Verify database connection
|
||||
try:
|
||||
ok = await check_db()
|
||||
if ok:
|
||||
print("Database connection OK")
|
||||
else:
|
||||
print("WARNING: Database check returned unexpected result")
|
||||
except Exception as e:
|
||||
print(f"WARNING: Database connection failed: {e}")
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Life OS",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# Static files
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
# Templates
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
# ---- Template globals and filters ----
|
||||
|
||||
@app.middleware("http")
|
||||
async def add_request_context(request: Request, call_next):
|
||||
"""Make environment available to all templates."""
|
||||
request.state.environment = os.getenv("ENVIRONMENT", "production")
|
||||
response = await call_next(request)
|
||||
return response
|
||||
|
||||
|
||||
# ---- Dashboard ----
|
||||
|
||||
@app.get("/")
|
||||
async def dashboard(request: Request):
|
||||
"""Main dashboard view."""
|
||||
from core.database import async_session_factory
|
||||
async with async_session_factory() as db:
|
||||
sidebar = await get_sidebar_data(db)
|
||||
|
||||
# Today's focus items
|
||||
from sqlalchemy import text
|
||||
result = await db.execute(text("""
|
||||
SELECT df.*, t.title, t.priority, t.status as task_status,
|
||||
t.project_id, p.name as project_name,
|
||||
d.name as domain_name, d.color as domain_color
|
||||
FROM daily_focus df
|
||||
JOIN tasks t ON df.task_id = t.id
|
||||
LEFT JOIN projects p ON t.project_id = p.id
|
||||
LEFT JOIN domains d ON t.domain_id = d.id
|
||||
WHERE df.focus_date = CURRENT_DATE AND df.is_deleted = false
|
||||
ORDER BY df.sort_order, df.created_at
|
||||
"""))
|
||||
focus_items = [dict(r._mapping) for r in result]
|
||||
|
||||
# Overdue tasks
|
||||
result = await db.execute(text("""
|
||||
SELECT t.*, d.name as domain_name, d.color as domain_color,
|
||||
p.name as project_name
|
||||
FROM tasks t
|
||||
LEFT JOIN domains d ON t.domain_id = d.id
|
||||
LEFT JOIN projects p ON t.project_id = p.id
|
||||
WHERE t.is_deleted = false AND t.status NOT IN ('done', 'cancelled')
|
||||
AND t.due_date < CURRENT_DATE
|
||||
ORDER BY t.due_date ASC
|
||||
LIMIT 10
|
||||
"""))
|
||||
overdue_tasks = [dict(r._mapping) for r in result]
|
||||
|
||||
# Upcoming deadlines (next 7 days)
|
||||
result = await db.execute(text("""
|
||||
SELECT t.*, d.name as domain_name, d.color as domain_color,
|
||||
p.name as project_name
|
||||
FROM tasks t
|
||||
LEFT JOIN domains d ON t.domain_id = d.id
|
||||
LEFT JOIN projects p ON t.project_id = p.id
|
||||
WHERE t.is_deleted = false AND t.status NOT IN ('done', 'cancelled')
|
||||
AND t.due_date >= CURRENT_DATE AND t.due_date <= CURRENT_DATE + INTERVAL '7 days'
|
||||
ORDER BY t.due_date ASC
|
||||
LIMIT 10
|
||||
"""))
|
||||
upcoming_tasks = [dict(r._mapping) for r in result]
|
||||
|
||||
# Task stats
|
||||
result = await db.execute(text("""
|
||||
SELECT
|
||||
count(*) FILTER (WHERE status NOT IN ('done', 'cancelled')) as open_tasks,
|
||||
count(*) FILTER (WHERE status = 'done' AND completed_at >= CURRENT_DATE - INTERVAL '7 days') as done_this_week,
|
||||
count(*) FILTER (WHERE status = 'in_progress') as in_progress
|
||||
FROM tasks WHERE is_deleted = false
|
||||
"""))
|
||||
stats = dict(result.first()._mapping)
|
||||
|
||||
return templates.TemplateResponse("dashboard.html", {
|
||||
"request": request,
|
||||
"sidebar": sidebar,
|
||||
"focus_items": focus_items,
|
||||
"overdue_tasks": overdue_tasks,
|
||||
"upcoming_tasks": upcoming_tasks,
|
||||
"stats": stats,
|
||||
"page_title": "Dashboard",
|
||||
"active_nav": "dashboard",
|
||||
})
|
||||
|
||||
|
||||
# ---- Health check ----
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
try:
|
||||
ok = await check_db()
|
||||
return {"status": "ok" if ok else "degraded", "database": ok}
|
||||
except Exception as e:
|
||||
return {"status": "error", "database": False, "error": str(e)}
|
||||
|
||||
|
||||
# ---- Include routers ----
|
||||
|
||||
app.include_router(domains_router.router)
|
||||
app.include_router(areas_router.router)
|
||||
app.include_router(projects_router.router)
|
||||
app.include_router(tasks_router.router)
|
||||
app.include_router(notes_router.router)
|
||||
app.include_router(links_router.router)
|
||||
app.include_router(focus_router.router)
|
||||
app.include_router(capture_router.router)
|
||||
app.include_router(contacts_router.router)
|
||||
10
requirements.txt
Normal file
10
requirements.txt
Normal file
@@ -0,0 +1,10 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
sqlalchemy[asyncio]==2.0.36
|
||||
asyncpg==0.30.0
|
||||
jinja2==3.1.4
|
||||
python-multipart==0.0.18
|
||||
python-dotenv==1.0.1
|
||||
pydantic==2.10.3
|
||||
pyyaml==6.0.2
|
||||
aiofiles==24.1.0
|
||||
0
routers/__init__.py
Normal file
0
routers/__init__.py
Normal file
122
routers/areas.py
Normal file
122
routers/areas.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""Areas: grouping within domains."""
|
||||
|
||||
from fastapi import APIRouter, Request, Form, Depends
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
from typing import Optional
|
||||
|
||||
from core.database import get_db
|
||||
from core.base_repository import BaseRepository
|
||||
from core.sidebar import get_sidebar_data
|
||||
|
||||
router = APIRouter(prefix="/areas", tags=["areas"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_areas(
|
||||
request: Request,
|
||||
domain_id: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
repo = BaseRepository("areas", db)
|
||||
sidebar = await get_sidebar_data(db)
|
||||
filters = {}
|
||||
if domain_id:
|
||||
filters["domain_id"] = domain_id
|
||||
|
||||
result = await db.execute(text("""
|
||||
SELECT a.*, d.name as domain_name, d.color as domain_color
|
||||
FROM areas a
|
||||
JOIN domains d ON a.domain_id = d.id
|
||||
WHERE a.is_deleted = false
|
||||
ORDER BY d.sort_order, d.name, a.sort_order, a.name
|
||||
"""))
|
||||
items = [dict(r._mapping) for r in result]
|
||||
if domain_id:
|
||||
items = [i for i in items if str(i["domain_id"]) == domain_id]
|
||||
|
||||
# Get domains for filter/form
|
||||
domains_repo = BaseRepository("domains", db)
|
||||
domains = await domains_repo.list()
|
||||
|
||||
return templates.TemplateResponse("areas.html", {
|
||||
"request": request, "sidebar": sidebar, "items": items,
|
||||
"domains": domains, "current_domain_id": domain_id or "",
|
||||
"page_title": "Areas", "active_nav": "areas",
|
||||
})
|
||||
|
||||
|
||||
@router.get("/create")
|
||||
async def create_form(
|
||||
request: Request,
|
||||
domain_id: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
sidebar = await get_sidebar_data(db)
|
||||
domains_repo = BaseRepository("domains", db)
|
||||
domains = await domains_repo.list()
|
||||
return templates.TemplateResponse("area_form.html", {
|
||||
"request": request, "sidebar": sidebar, "domains": domains,
|
||||
"page_title": "New Area", "active_nav": "areas",
|
||||
"item": None, "prefill_domain_id": domain_id or "",
|
||||
})
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def create_area(
|
||||
request: Request,
|
||||
name: str = Form(...),
|
||||
domain_id: str = Form(...),
|
||||
description: Optional[str] = Form(None),
|
||||
status: str = Form("active"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
repo = BaseRepository("areas", db)
|
||||
await repo.create({
|
||||
"name": name, "domain_id": domain_id,
|
||||
"description": description, "status": status,
|
||||
})
|
||||
return RedirectResponse(url="/areas", status_code=303)
|
||||
|
||||
|
||||
@router.get("/{area_id}/edit")
|
||||
async def edit_form(area_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
repo = BaseRepository("areas", db)
|
||||
sidebar = await get_sidebar_data(db)
|
||||
item = await repo.get(area_id)
|
||||
if not item:
|
||||
return RedirectResponse(url="/areas", status_code=303)
|
||||
domains_repo = BaseRepository("domains", db)
|
||||
domains = await domains_repo.list()
|
||||
return templates.TemplateResponse("area_form.html", {
|
||||
"request": request, "sidebar": sidebar, "domains": domains,
|
||||
"page_title": f"Edit {item['name']}", "active_nav": "areas",
|
||||
"item": item, "prefill_domain_id": "",
|
||||
})
|
||||
|
||||
|
||||
@router.post("/{area_id}/edit")
|
||||
async def update_area(
|
||||
area_id: str,
|
||||
name: str = Form(...),
|
||||
domain_id: str = Form(...),
|
||||
description: Optional[str] = Form(None),
|
||||
status: str = Form("active"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
repo = BaseRepository("areas", db)
|
||||
await repo.update(area_id, {
|
||||
"name": name, "domain_id": domain_id,
|
||||
"description": description, "status": status,
|
||||
})
|
||||
return RedirectResponse(url="/areas", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{area_id}/delete")
|
||||
async def delete_area(area_id: str, db: AsyncSession = Depends(get_db)):
|
||||
repo = BaseRepository("areas", db)
|
||||
await repo.soft_delete(area_id)
|
||||
return RedirectResponse(url="/areas", status_code=303)
|
||||
92
routers/capture.py
Normal file
92
routers/capture.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""Capture: quick text capture queue with conversion."""
|
||||
|
||||
from fastapi import APIRouter, Request, Form, Depends
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
from typing import Optional
|
||||
|
||||
from core.database import get_db
|
||||
from core.base_repository import BaseRepository
|
||||
from core.sidebar import get_sidebar_data
|
||||
|
||||
router = APIRouter(prefix="/capture", tags=["capture"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_capture(request: Request, show: str = "unprocessed", db: AsyncSession = Depends(get_db)):
|
||||
sidebar = await get_sidebar_data(db)
|
||||
if show == "all":
|
||||
filters = {}
|
||||
else:
|
||||
filters = {"processed": False}
|
||||
|
||||
result = await db.execute(text("""
|
||||
SELECT * FROM capture WHERE is_deleted = false
|
||||
AND (:show_all OR processed = false)
|
||||
ORDER BY created_at DESC
|
||||
"""), {"show_all": show == "all"})
|
||||
items = [dict(r._mapping) for r in result]
|
||||
|
||||
return templates.TemplateResponse("capture.html", {
|
||||
"request": request, "sidebar": sidebar, "items": items,
|
||||
"show": show,
|
||||
"page_title": "Capture", "active_nav": "capture",
|
||||
})
|
||||
|
||||
|
||||
@router.post("/add")
|
||||
async def add_capture(
|
||||
request: Request,
|
||||
raw_text: str = Form(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
repo = BaseRepository("capture", db)
|
||||
# Multi-line: split into individual items
|
||||
lines = [l.strip() for l in raw_text.strip().split("\n") if l.strip()]
|
||||
for line in lines:
|
||||
await repo.create({"raw_text": line, "processed": False})
|
||||
return RedirectResponse(url="/capture", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{capture_id}/to-task")
|
||||
async def convert_to_task(
|
||||
capture_id: str,
|
||||
request: Request,
|
||||
domain_id: str = Form(...),
|
||||
project_id: Optional[str] = Form(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
capture_repo = BaseRepository("capture", db)
|
||||
item = await capture_repo.get(capture_id)
|
||||
if not item:
|
||||
return RedirectResponse(url="/capture", status_code=303)
|
||||
|
||||
task_repo = BaseRepository("tasks", db)
|
||||
data = {"title": item["raw_text"], "domain_id": domain_id, "status": "open", "priority": 3}
|
||||
if project_id and project_id.strip():
|
||||
data["project_id"] = project_id
|
||||
task = await task_repo.create(data)
|
||||
|
||||
await capture_repo.update(capture_id, {
|
||||
"processed": True,
|
||||
"converted_to_type": "task",
|
||||
"converted_to_id": str(task["id"]),
|
||||
})
|
||||
return RedirectResponse(url="/capture", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{capture_id}/dismiss")
|
||||
async def dismiss_capture(capture_id: str, db: AsyncSession = Depends(get_db)):
|
||||
repo = BaseRepository("capture", db)
|
||||
await repo.update(capture_id, {"processed": True})
|
||||
return RedirectResponse(url="/capture", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{capture_id}/delete")
|
||||
async def delete_capture(capture_id: str, db: AsyncSession = Depends(get_db)):
|
||||
repo = BaseRepository("capture", db)
|
||||
await repo.soft_delete(capture_id)
|
||||
return RedirectResponse(url="/capture", status_code=303)
|
||||
126
routers/contacts.py
Normal file
126
routers/contacts.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""Contacts: people directory for CRM."""
|
||||
|
||||
from fastapi import APIRouter, Request, Form, Depends
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
from typing import Optional
|
||||
|
||||
from core.database import get_db
|
||||
from core.base_repository import BaseRepository
|
||||
from core.sidebar import get_sidebar_data
|
||||
|
||||
router = APIRouter(prefix="/contacts", tags=["contacts"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_contacts(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
sidebar = await get_sidebar_data(db)
|
||||
result = await db.execute(text("""
|
||||
SELECT * FROM contacts WHERE is_deleted = false
|
||||
ORDER BY sort_order, first_name, last_name
|
||||
"""))
|
||||
items = [dict(r._mapping) for r in result]
|
||||
return templates.TemplateResponse("contacts.html", {
|
||||
"request": request, "sidebar": sidebar, "items": items,
|
||||
"page_title": "Contacts", "active_nav": "contacts",
|
||||
})
|
||||
|
||||
|
||||
@router.get("/create")
|
||||
async def create_form(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
sidebar = await get_sidebar_data(db)
|
||||
return templates.TemplateResponse("contact_form.html", {
|
||||
"request": request, "sidebar": sidebar,
|
||||
"page_title": "New Contact", "active_nav": "contacts",
|
||||
"item": None,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def create_contact(
|
||||
request: Request,
|
||||
first_name: str = Form(...),
|
||||
last_name: Optional[str] = Form(None),
|
||||
company: Optional[str] = Form(None),
|
||||
role: Optional[str] = Form(None),
|
||||
email: Optional[str] = Form(None),
|
||||
phone: Optional[str] = Form(None),
|
||||
notes: Optional[str] = Form(None),
|
||||
tags: Optional[str] = Form(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
repo = BaseRepository("contacts", db)
|
||||
data = {
|
||||
"first_name": first_name, "last_name": last_name,
|
||||
"company": company, "role": role, "email": email,
|
||||
"phone": phone, "notes": notes,
|
||||
}
|
||||
if tags and tags.strip():
|
||||
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
|
||||
await repo.create(data)
|
||||
return RedirectResponse(url="/contacts", status_code=303)
|
||||
|
||||
|
||||
@router.get("/{contact_id}")
|
||||
async def contact_detail(contact_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
repo = BaseRepository("contacts", db)
|
||||
sidebar = await get_sidebar_data(db)
|
||||
item = await repo.get(contact_id)
|
||||
if not item:
|
||||
return RedirectResponse(url="/contacts", status_code=303)
|
||||
return templates.TemplateResponse("contact_detail.html", {
|
||||
"request": request, "sidebar": sidebar, "item": item,
|
||||
"page_title": f"{item['first_name']} {item.get('last_name', '')}".strip(),
|
||||
"active_nav": "contacts",
|
||||
})
|
||||
|
||||
|
||||
@router.get("/{contact_id}/edit")
|
||||
async def edit_form(contact_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
repo = BaseRepository("contacts", db)
|
||||
sidebar = await get_sidebar_data(db)
|
||||
item = await repo.get(contact_id)
|
||||
if not item:
|
||||
return RedirectResponse(url="/contacts", status_code=303)
|
||||
return templates.TemplateResponse("contact_form.html", {
|
||||
"request": request, "sidebar": sidebar,
|
||||
"page_title": "Edit Contact", "active_nav": "contacts",
|
||||
"item": item,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/{contact_id}/edit")
|
||||
async def update_contact(
|
||||
contact_id: str,
|
||||
first_name: str = Form(...),
|
||||
last_name: Optional[str] = Form(None),
|
||||
company: Optional[str] = Form(None),
|
||||
role: Optional[str] = Form(None),
|
||||
email: Optional[str] = Form(None),
|
||||
phone: Optional[str] = Form(None),
|
||||
notes: Optional[str] = Form(None),
|
||||
tags: Optional[str] = Form(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
repo = BaseRepository("contacts", db)
|
||||
data = {
|
||||
"first_name": first_name, "last_name": last_name,
|
||||
"company": company, "role": role, "email": email,
|
||||
"phone": phone, "notes": notes,
|
||||
}
|
||||
if tags and tags.strip():
|
||||
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
|
||||
else:
|
||||
data["tags"] = None
|
||||
await repo.update(contact_id, data)
|
||||
return RedirectResponse(url=f"/contacts/{contact_id}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{contact_id}/delete")
|
||||
async def delete_contact(contact_id: str, db: AsyncSession = Depends(get_db)):
|
||||
repo = BaseRepository("contacts", db)
|
||||
await repo.soft_delete(contact_id)
|
||||
return RedirectResponse(url="/contacts", status_code=303)
|
||||
83
routers/domains.py
Normal file
83
routers/domains.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""Domains: top-level organizational buckets."""
|
||||
|
||||
from fastapi import APIRouter, Request, Form, Depends
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import Optional
|
||||
|
||||
from core.database import get_db
|
||||
from core.base_repository import BaseRepository
|
||||
from core.sidebar import get_sidebar_data
|
||||
|
||||
router = APIRouter(prefix="/domains", tags=["domains"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_domains(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
repo = BaseRepository("domains", db)
|
||||
sidebar = await get_sidebar_data(db)
|
||||
items = await repo.list(sort="sort_order")
|
||||
return templates.TemplateResponse("domains.html", {
|
||||
"request": request, "sidebar": sidebar, "items": items,
|
||||
"page_title": "Domains", "active_nav": "domains",
|
||||
})
|
||||
|
||||
|
||||
@router.get("/create")
|
||||
async def create_form(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
sidebar = await get_sidebar_data(db)
|
||||
return templates.TemplateResponse("domain_form.html", {
|
||||
"request": request, "sidebar": sidebar,
|
||||
"page_title": "New Domain", "active_nav": "domains",
|
||||
"item": None,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def create_domain(
|
||||
request: Request,
|
||||
name: str = Form(...),
|
||||
color: Optional[str] = Form(None),
|
||||
description: Optional[str] = Form(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
repo = BaseRepository("domains", db)
|
||||
domain = await repo.create({"name": name, "color": color, "description": description})
|
||||
return RedirectResponse(url="/domains", status_code=303)
|
||||
|
||||
|
||||
@router.get("/{domain_id}/edit")
|
||||
async def edit_form(domain_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
repo = BaseRepository("domains", db)
|
||||
sidebar = await get_sidebar_data(db)
|
||||
item = await repo.get(domain_id)
|
||||
if not item:
|
||||
return RedirectResponse(url="/domains", status_code=303)
|
||||
return templates.TemplateResponse("domain_form.html", {
|
||||
"request": request, "sidebar": sidebar,
|
||||
"page_title": f"Edit {item['name']}", "active_nav": "domains",
|
||||
"item": item,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/{domain_id}/edit")
|
||||
async def update_domain(
|
||||
domain_id: str,
|
||||
request: Request,
|
||||
name: str = Form(...),
|
||||
color: Optional[str] = Form(None),
|
||||
description: Optional[str] = Form(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
repo = BaseRepository("domains", db)
|
||||
await repo.update(domain_id, {"name": name, "color": color, "description": description})
|
||||
return RedirectResponse(url="/domains", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{domain_id}/delete")
|
||||
async def delete_domain(domain_id: str, db: AsyncSession = Depends(get_db)):
|
||||
repo = BaseRepository("domains", db)
|
||||
await repo.soft_delete(domain_id)
|
||||
return RedirectResponse(url="/domains", status_code=303)
|
||||
111
routers/focus.py
Normal file
111
routers/focus.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""Daily Focus: date-scoped task commitment list."""
|
||||
|
||||
from fastapi import APIRouter, Request, Form, Depends
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
from typing import Optional
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from core.database import get_db
|
||||
from core.base_repository import BaseRepository
|
||||
from core.sidebar import get_sidebar_data
|
||||
|
||||
router = APIRouter(prefix="/focus", tags=["focus"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def focus_view(request: Request, focus_date: Optional[str] = None, db: AsyncSession = Depends(get_db)):
|
||||
sidebar = await get_sidebar_data(db)
|
||||
target_date = focus_date or str(date.today())
|
||||
|
||||
result = await db.execute(text("""
|
||||
SELECT df.*, t.title, t.priority, t.status as task_status,
|
||||
t.project_id, t.due_date, t.estimated_minutes,
|
||||
p.name as project_name,
|
||||
d.name as domain_name, d.color as domain_color
|
||||
FROM daily_focus df
|
||||
JOIN tasks t ON df.task_id = t.id
|
||||
LEFT JOIN projects p ON t.project_id = p.id
|
||||
LEFT JOIN domains d ON t.domain_id = d.id
|
||||
WHERE df.focus_date = :target_date AND df.is_deleted = false
|
||||
ORDER BY df.sort_order, df.created_at
|
||||
"""), {"target_date": target_date})
|
||||
items = [dict(r._mapping) for r in result]
|
||||
|
||||
# Available tasks to add (open, not already in today's focus)
|
||||
result = await db.execute(text("""
|
||||
SELECT t.id, t.title, t.priority, t.due_date,
|
||||
p.name as project_name, d.name as domain_name
|
||||
FROM tasks t
|
||||
LEFT JOIN projects p ON t.project_id = p.id
|
||||
LEFT JOIN domains d ON t.domain_id = d.id
|
||||
WHERE t.is_deleted = false AND t.status NOT IN ('done', 'cancelled')
|
||||
AND t.id NOT IN (
|
||||
SELECT task_id FROM daily_focus
|
||||
WHERE focus_date = :target_date AND is_deleted = false
|
||||
)
|
||||
ORDER BY t.priority ASC, t.due_date ASC NULLS LAST
|
||||
LIMIT 50
|
||||
"""), {"target_date": target_date})
|
||||
available_tasks = [dict(r._mapping) for r in result]
|
||||
|
||||
# Estimated total minutes
|
||||
total_est = sum(i.get("estimated_minutes") or 0 for i in items)
|
||||
|
||||
return templates.TemplateResponse("focus.html", {
|
||||
"request": request, "sidebar": sidebar,
|
||||
"items": items, "available_tasks": available_tasks,
|
||||
"focus_date": target_date,
|
||||
"total_estimated": total_est,
|
||||
"page_title": "Daily Focus", "active_nav": "focus",
|
||||
})
|
||||
|
||||
|
||||
@router.post("/add")
|
||||
async def add_to_focus(
|
||||
request: Request,
|
||||
task_id: str = Form(...),
|
||||
focus_date: str = Form(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
repo = BaseRepository("daily_focus", db)
|
||||
# Get next sort order
|
||||
result = await db.execute(text("""
|
||||
SELECT COALESCE(MAX(sort_order), 0) + 10 FROM daily_focus
|
||||
WHERE focus_date = :fd AND is_deleted = false
|
||||
"""), {"fd": focus_date})
|
||||
next_order = result.scalar()
|
||||
|
||||
await repo.create({
|
||||
"task_id": task_id, "focus_date": focus_date,
|
||||
"sort_order": next_order, "completed": False,
|
||||
})
|
||||
return RedirectResponse(url=f"/focus?focus_date={focus_date}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{focus_id}/toggle")
|
||||
async def toggle_focus(focus_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
repo = BaseRepository("daily_focus", db)
|
||||
item = await repo.get(focus_id)
|
||||
if item:
|
||||
await repo.update(focus_id, {"completed": not item["completed"]})
|
||||
# Also toggle the task status
|
||||
task_repo = BaseRepository("tasks", db)
|
||||
if not item["completed"]:
|
||||
await task_repo.update(item["task_id"], {"status": "done", "completed_at": datetime.now(timezone.utc)})
|
||||
else:
|
||||
await task_repo.update(item["task_id"], {"status": "open", "completed_at": None})
|
||||
focus_date = item["focus_date"] if item else date.today()
|
||||
return RedirectResponse(url=f"/focus?focus_date={focus_date}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{focus_id}/remove")
|
||||
async def remove_from_focus(focus_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
repo = BaseRepository("daily_focus", db)
|
||||
item = await repo.get(focus_id)
|
||||
await repo.soft_delete(focus_id)
|
||||
focus_date = item["focus_date"] if item else date.today()
|
||||
return RedirectResponse(url=f"/focus?focus_date={focus_date}", status_code=303)
|
||||
113
routers/links.py
Normal file
113
routers/links.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""Links: URL references attached to domains/projects."""
|
||||
|
||||
from fastapi import APIRouter, Request, Form, Depends
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
from typing import Optional
|
||||
|
||||
from core.database import get_db
|
||||
from core.base_repository import BaseRepository
|
||||
from core.sidebar import get_sidebar_data
|
||||
|
||||
router = APIRouter(prefix="/links", tags=["links"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_links(request: Request, domain_id: Optional[str] = None, db: AsyncSession = Depends(get_db)):
|
||||
sidebar = await get_sidebar_data(db)
|
||||
where_clauses = ["l.is_deleted = false"]
|
||||
params = {}
|
||||
if domain_id:
|
||||
where_clauses.append("l.domain_id = :domain_id")
|
||||
params["domain_id"] = domain_id
|
||||
where_sql = " AND ".join(where_clauses)
|
||||
|
||||
result = await db.execute(text(f"""
|
||||
SELECT l.*, d.name as domain_name, d.color as domain_color, p.name as project_name
|
||||
FROM links l
|
||||
LEFT JOIN domains d ON l.domain_id = d.id
|
||||
LEFT JOIN projects p ON l.project_id = p.id
|
||||
WHERE {where_sql} ORDER BY l.sort_order, l.created_at
|
||||
"""), params)
|
||||
items = [dict(r._mapping) for r in result]
|
||||
|
||||
domains_repo = BaseRepository("domains", db)
|
||||
domains = await domains_repo.list()
|
||||
|
||||
return templates.TemplateResponse("links.html", {
|
||||
"request": request, "sidebar": sidebar, "items": items, "domains": domains,
|
||||
"current_domain_id": domain_id or "",
|
||||
"page_title": "Links", "active_nav": "links",
|
||||
})
|
||||
|
||||
|
||||
@router.get("/create")
|
||||
async def create_form(request: Request, domain_id: Optional[str] = None, project_id: Optional[str] = None, db: AsyncSession = Depends(get_db)):
|
||||
sidebar = await get_sidebar_data(db)
|
||||
domains_repo = BaseRepository("domains", db)
|
||||
domains = await domains_repo.list()
|
||||
projects_repo = BaseRepository("projects", db)
|
||||
projects = await projects_repo.list()
|
||||
return templates.TemplateResponse("link_form.html", {
|
||||
"request": request, "sidebar": sidebar, "domains": domains, "projects": projects,
|
||||
"page_title": "New Link", "active_nav": "links",
|
||||
"item": None, "prefill_domain_id": domain_id or "", "prefill_project_id": project_id or "",
|
||||
})
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def create_link(
|
||||
request: Request, label: str = Form(...), url: str = Form(...),
|
||||
domain_id: str = Form(...), project_id: Optional[str] = Form(None),
|
||||
description: Optional[str] = Form(None), db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
repo = BaseRepository("links", db)
|
||||
data = {"label": label, "url": url, "domain_id": domain_id, "description": description}
|
||||
if project_id and project_id.strip():
|
||||
data["project_id"] = project_id
|
||||
await repo.create(data)
|
||||
referer = request.headers.get("referer", "/links")
|
||||
return RedirectResponse(url="/links", status_code=303)
|
||||
|
||||
|
||||
@router.get("/{link_id}/edit")
|
||||
async def edit_form(link_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
repo = BaseRepository("links", db)
|
||||
sidebar = await get_sidebar_data(db)
|
||||
item = await repo.get(link_id)
|
||||
if not item:
|
||||
return RedirectResponse(url="/links", status_code=303)
|
||||
domains_repo = BaseRepository("domains", db)
|
||||
domains = await domains_repo.list()
|
||||
projects_repo = BaseRepository("projects", db)
|
||||
projects = await projects_repo.list()
|
||||
return templates.TemplateResponse("link_form.html", {
|
||||
"request": request, "sidebar": sidebar, "domains": domains, "projects": projects,
|
||||
"page_title": "Edit Link", "active_nav": "links",
|
||||
"item": item, "prefill_domain_id": "", "prefill_project_id": "",
|
||||
})
|
||||
|
||||
|
||||
@router.post("/{link_id}/edit")
|
||||
async def update_link(
|
||||
link_id: str, label: str = Form(...), url: str = Form(...),
|
||||
domain_id: str = Form(...), project_id: Optional[str] = Form(None),
|
||||
description: Optional[str] = Form(None), db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
repo = BaseRepository("links", db)
|
||||
await repo.update(link_id, {
|
||||
"label": label, "url": url, "domain_id": domain_id,
|
||||
"project_id": project_id if project_id and project_id.strip() else None,
|
||||
"description": description,
|
||||
})
|
||||
return RedirectResponse(url="/links", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{link_id}/delete")
|
||||
async def delete_link(link_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
repo = BaseRepository("links", db)
|
||||
await repo.soft_delete(link_id)
|
||||
return RedirectResponse(url=request.headers.get("referer", "/links"), status_code=303)
|
||||
181
routers/notes.py
Normal file
181
routers/notes.py
Normal file
@@ -0,0 +1,181 @@
|
||||
"""Notes: knowledge documents with project associations."""
|
||||
|
||||
from fastapi import APIRouter, Request, Form, Depends
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
from typing import Optional
|
||||
|
||||
from core.database import get_db
|
||||
from core.base_repository import BaseRepository
|
||||
from core.sidebar import get_sidebar_data
|
||||
|
||||
router = APIRouter(prefix="/notes", tags=["notes"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_notes(
|
||||
request: Request,
|
||||
domain_id: Optional[str] = None,
|
||||
project_id: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
sidebar = await get_sidebar_data(db)
|
||||
where_clauses = ["n.is_deleted = false"]
|
||||
params = {}
|
||||
if domain_id:
|
||||
where_clauses.append("n.domain_id = :domain_id")
|
||||
params["domain_id"] = domain_id
|
||||
if project_id:
|
||||
where_clauses.append("n.project_id = :project_id")
|
||||
params["project_id"] = project_id
|
||||
where_sql = " AND ".join(where_clauses)
|
||||
|
||||
result = await db.execute(text(f"""
|
||||
SELECT n.*, d.name as domain_name, d.color as domain_color,
|
||||
p.name as project_name
|
||||
FROM notes n
|
||||
LEFT JOIN domains d ON n.domain_id = d.id
|
||||
LEFT JOIN projects p ON n.project_id = p.id
|
||||
WHERE {where_sql}
|
||||
ORDER BY n.updated_at DESC
|
||||
"""), params)
|
||||
items = [dict(r._mapping) for r in result]
|
||||
|
||||
domains_repo = BaseRepository("domains", db)
|
||||
domains = await domains_repo.list()
|
||||
|
||||
return templates.TemplateResponse("notes.html", {
|
||||
"request": request, "sidebar": sidebar, "items": items,
|
||||
"domains": domains,
|
||||
"current_domain_id": domain_id or "",
|
||||
"current_project_id": project_id or "",
|
||||
"page_title": "Notes", "active_nav": "notes",
|
||||
})
|
||||
|
||||
|
||||
@router.get("/create")
|
||||
async def create_form(
|
||||
request: Request,
|
||||
domain_id: Optional[str] = None,
|
||||
project_id: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
sidebar = await get_sidebar_data(db)
|
||||
domains_repo = BaseRepository("domains", db)
|
||||
domains = await domains_repo.list()
|
||||
projects_repo = BaseRepository("projects", db)
|
||||
projects = await projects_repo.list()
|
||||
return templates.TemplateResponse("note_form.html", {
|
||||
"request": request, "sidebar": sidebar,
|
||||
"domains": domains, "projects": projects,
|
||||
"page_title": "New Note", "active_nav": "notes",
|
||||
"item": None,
|
||||
"prefill_domain_id": domain_id or "",
|
||||
"prefill_project_id": project_id or "",
|
||||
})
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def create_note(
|
||||
request: Request,
|
||||
title: str = Form(...),
|
||||
domain_id: str = Form(...),
|
||||
project_id: Optional[str] = Form(None),
|
||||
body: Optional[str] = Form(None),
|
||||
content_format: str = Form("rich"),
|
||||
tags: Optional[str] = Form(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
repo = BaseRepository("notes", db)
|
||||
data = {
|
||||
"title": title, "domain_id": domain_id,
|
||||
"body": body, "content_format": content_format,
|
||||
}
|
||||
if project_id and project_id.strip():
|
||||
data["project_id"] = project_id
|
||||
if tags and tags.strip():
|
||||
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
|
||||
note = await repo.create(data)
|
||||
return RedirectResponse(url=f"/notes/{note['id']}", status_code=303)
|
||||
|
||||
|
||||
@router.get("/{note_id}")
|
||||
async def note_detail(note_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
repo = BaseRepository("notes", db)
|
||||
sidebar = await get_sidebar_data(db)
|
||||
item = await repo.get(note_id)
|
||||
if not item:
|
||||
return RedirectResponse(url="/notes", status_code=303)
|
||||
|
||||
domain = None
|
||||
if item.get("domain_id"):
|
||||
result = await db.execute(text("SELECT name, color FROM domains WHERE id = :id"), {"id": str(item["domain_id"])})
|
||||
row = result.first()
|
||||
domain = dict(row._mapping) if row else None
|
||||
|
||||
project = None
|
||||
if item.get("project_id"):
|
||||
result = await db.execute(text("SELECT id, name FROM projects WHERE id = :id"), {"id": str(item["project_id"])})
|
||||
row = result.first()
|
||||
project = dict(row._mapping) if row else None
|
||||
|
||||
return templates.TemplateResponse("note_detail.html", {
|
||||
"request": request, "sidebar": sidebar, "item": item,
|
||||
"domain": domain, "project": project,
|
||||
"page_title": item["title"], "active_nav": "notes",
|
||||
})
|
||||
|
||||
|
||||
@router.get("/{note_id}/edit")
|
||||
async def edit_form(note_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
repo = BaseRepository("notes", db)
|
||||
sidebar = await get_sidebar_data(db)
|
||||
item = await repo.get(note_id)
|
||||
if not item:
|
||||
return RedirectResponse(url="/notes", status_code=303)
|
||||
domains_repo = BaseRepository("domains", db)
|
||||
domains = await domains_repo.list()
|
||||
projects_repo = BaseRepository("projects", db)
|
||||
projects = await projects_repo.list()
|
||||
return templates.TemplateResponse("note_form.html", {
|
||||
"request": request, "sidebar": sidebar,
|
||||
"domains": domains, "projects": projects,
|
||||
"page_title": f"Edit Note", "active_nav": "notes",
|
||||
"item": item, "prefill_domain_id": "", "prefill_project_id": "",
|
||||
})
|
||||
|
||||
|
||||
@router.post("/{note_id}/edit")
|
||||
async def update_note(
|
||||
note_id: str,
|
||||
title: str = Form(...),
|
||||
domain_id: str = Form(...),
|
||||
project_id: Optional[str] = Form(None),
|
||||
body: Optional[str] = Form(None),
|
||||
content_format: str = Form("rich"),
|
||||
tags: Optional[str] = Form(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
repo = BaseRepository("notes", db)
|
||||
data = {
|
||||
"title": title, "domain_id": domain_id, "body": body,
|
||||
"content_format": content_format,
|
||||
"project_id": project_id if project_id and project_id.strip() else None,
|
||||
}
|
||||
if tags and tags.strip():
|
||||
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
|
||||
else:
|
||||
data["tags"] = None
|
||||
await repo.update(note_id, data)
|
||||
return RedirectResponse(url=f"/notes/{note_id}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{note_id}/delete")
|
||||
async def delete_note(note_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
repo = BaseRepository("notes", db)
|
||||
await repo.soft_delete(note_id)
|
||||
referer = request.headers.get("referer", "/notes")
|
||||
return RedirectResponse(url=referer, status_code=303)
|
||||
248
routers/projects.py
Normal file
248
routers/projects.py
Normal file
@@ -0,0 +1,248 @@
|
||||
"""Projects: organizational unit within domain/area hierarchy."""
|
||||
|
||||
from fastapi import APIRouter, Request, Form, Depends
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
from typing import Optional
|
||||
|
||||
from core.database import get_db
|
||||
from core.base_repository import BaseRepository
|
||||
from core.sidebar import get_sidebar_data
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["projects"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_projects(
|
||||
request: Request,
|
||||
domain_id: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
sidebar = await get_sidebar_data(db)
|
||||
|
||||
# Build query with joins for hierarchy display
|
||||
where_clauses = ["p.is_deleted = false"]
|
||||
params = {}
|
||||
if domain_id:
|
||||
where_clauses.append("p.domain_id = :domain_id")
|
||||
params["domain_id"] = domain_id
|
||||
if status:
|
||||
where_clauses.append("p.status = :status")
|
||||
params["status"] = status
|
||||
|
||||
where_sql = " AND ".join(where_clauses)
|
||||
|
||||
result = await db.execute(text(f"""
|
||||
SELECT p.*,
|
||||
d.name as domain_name, d.color as domain_color,
|
||||
a.name as area_name,
|
||||
(SELECT count(*) FROM tasks t WHERE t.project_id = p.id AND t.is_deleted = false) as task_count,
|
||||
(SELECT count(*) FROM tasks t WHERE t.project_id = p.id AND t.is_deleted = false AND t.status = 'done') as done_count
|
||||
FROM projects p
|
||||
JOIN domains d ON p.domain_id = d.id
|
||||
LEFT JOIN areas a ON p.area_id = a.id
|
||||
WHERE {where_sql}
|
||||
ORDER BY d.sort_order, d.name, a.sort_order, a.name, p.sort_order, p.name
|
||||
"""), params)
|
||||
items = [dict(r._mapping) for r in result]
|
||||
|
||||
# Calculate progress percentage
|
||||
for item in items:
|
||||
total = item["task_count"] or 0
|
||||
done = item["done_count"] or 0
|
||||
item["progress"] = round((done / total * 100) if total > 0 else 0)
|
||||
|
||||
domains_repo = BaseRepository("domains", db)
|
||||
domains = await domains_repo.list()
|
||||
|
||||
return templates.TemplateResponse("projects.html", {
|
||||
"request": request, "sidebar": sidebar, "items": items,
|
||||
"domains": domains,
|
||||
"current_domain_id": domain_id or "",
|
||||
"current_status": status or "",
|
||||
"page_title": "Projects", "active_nav": "projects",
|
||||
})
|
||||
|
||||
|
||||
@router.get("/create")
|
||||
async def create_form(
|
||||
request: Request,
|
||||
domain_id: Optional[str] = None,
|
||||
area_id: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
sidebar = await get_sidebar_data(db)
|
||||
domains_repo = BaseRepository("domains", db)
|
||||
domains = await domains_repo.list()
|
||||
areas_repo = BaseRepository("areas", db)
|
||||
areas = await areas_repo.list()
|
||||
|
||||
return templates.TemplateResponse("project_form.html", {
|
||||
"request": request, "sidebar": sidebar,
|
||||
"domains": domains, "areas": areas,
|
||||
"page_title": "New Project", "active_nav": "projects",
|
||||
"item": None,
|
||||
"prefill_domain_id": domain_id or "",
|
||||
"prefill_area_id": area_id or "",
|
||||
})
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def create_project(
|
||||
request: Request,
|
||||
name: str = Form(...),
|
||||
domain_id: str = Form(...),
|
||||
area_id: Optional[str] = Form(None),
|
||||
description: Optional[str] = Form(None),
|
||||
status: str = Form("active"),
|
||||
priority: int = Form(3),
|
||||
start_date: Optional[str] = Form(None),
|
||||
target_date: Optional[str] = Form(None),
|
||||
tags: Optional[str] = Form(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
repo = BaseRepository("projects", db)
|
||||
data = {
|
||||
"name": name, "domain_id": domain_id,
|
||||
"description": description, "status": status,
|
||||
"priority": priority,
|
||||
}
|
||||
if area_id and area_id.strip():
|
||||
data["area_id"] = area_id
|
||||
if start_date and start_date.strip():
|
||||
data["start_date"] = start_date
|
||||
if target_date and target_date.strip():
|
||||
data["target_date"] = target_date
|
||||
if tags and tags.strip():
|
||||
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
|
||||
|
||||
project = await repo.create(data)
|
||||
return RedirectResponse(url=f"/projects/{project['id']}", status_code=303)
|
||||
|
||||
|
||||
@router.get("/{project_id}")
|
||||
async def project_detail(
|
||||
project_id: str,
|
||||
request: Request,
|
||||
tab: str = "tasks",
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
repo = BaseRepository("projects", db)
|
||||
sidebar = await get_sidebar_data(db)
|
||||
item = await repo.get(project_id)
|
||||
if not item:
|
||||
return RedirectResponse(url="/projects", status_code=303)
|
||||
|
||||
# Get domain and area names
|
||||
result = await db.execute(text(
|
||||
"SELECT name, color FROM domains WHERE id = :id"
|
||||
), {"id": str(item["domain_id"])})
|
||||
domain = dict(result.first()._mapping) if result else {}
|
||||
|
||||
area = None
|
||||
if item.get("area_id"):
|
||||
result = await db.execute(text(
|
||||
"SELECT name FROM areas WHERE id = :id"
|
||||
), {"id": str(item["area_id"])})
|
||||
row = result.first()
|
||||
area = dict(row._mapping) if row else None
|
||||
|
||||
# Tasks for this project
|
||||
result = await db.execute(text("""
|
||||
SELECT t.*, d.name as domain_name, d.color as domain_color
|
||||
FROM tasks t
|
||||
LEFT JOIN domains d ON t.domain_id = d.id
|
||||
WHERE t.project_id = :pid AND t.is_deleted = false
|
||||
ORDER BY t.sort_order, t.created_at
|
||||
"""), {"pid": project_id})
|
||||
tasks = [dict(r._mapping) for r in result]
|
||||
|
||||
# Notes
|
||||
result = await db.execute(text("""
|
||||
SELECT * FROM notes WHERE project_id = :pid AND is_deleted = false
|
||||
ORDER BY sort_order, created_at DESC
|
||||
"""), {"pid": project_id})
|
||||
notes = [dict(r._mapping) for r in result]
|
||||
|
||||
# Links
|
||||
result = await db.execute(text("""
|
||||
SELECT * FROM links WHERE project_id = :pid AND is_deleted = false
|
||||
ORDER BY sort_order, created_at
|
||||
"""), {"pid": project_id})
|
||||
links = [dict(r._mapping) for r in result]
|
||||
|
||||
# Progress
|
||||
total = len(tasks)
|
||||
done = len([t for t in tasks if t["status"] == "done"])
|
||||
progress = round((done / total * 100) if total > 0 else 0)
|
||||
|
||||
return templates.TemplateResponse("project_detail.html", {
|
||||
"request": request, "sidebar": sidebar, "item": item,
|
||||
"domain": domain, "area": area,
|
||||
"tasks": tasks, "notes": notes, "links": links,
|
||||
"progress": progress, "task_count": total, "done_count": done,
|
||||
"tab": tab,
|
||||
"page_title": item["name"], "active_nav": "projects",
|
||||
})
|
||||
|
||||
|
||||
@router.get("/{project_id}/edit")
|
||||
async def edit_form(project_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
repo = BaseRepository("projects", db)
|
||||
sidebar = await get_sidebar_data(db)
|
||||
item = await repo.get(project_id)
|
||||
if not item:
|
||||
return RedirectResponse(url="/projects", status_code=303)
|
||||
domains_repo = BaseRepository("domains", db)
|
||||
domains = await domains_repo.list()
|
||||
areas_repo = BaseRepository("areas", db)
|
||||
areas = await areas_repo.list()
|
||||
return templates.TemplateResponse("project_form.html", {
|
||||
"request": request, "sidebar": sidebar,
|
||||
"domains": domains, "areas": areas,
|
||||
"page_title": f"Edit {item['name']}", "active_nav": "projects",
|
||||
"item": item, "prefill_domain_id": "", "prefill_area_id": "",
|
||||
})
|
||||
|
||||
|
||||
@router.post("/{project_id}/edit")
|
||||
async def update_project(
|
||||
project_id: str,
|
||||
name: str = Form(...),
|
||||
domain_id: str = Form(...),
|
||||
area_id: Optional[str] = Form(None),
|
||||
description: Optional[str] = Form(None),
|
||||
status: str = Form("active"),
|
||||
priority: int = Form(3),
|
||||
start_date: Optional[str] = Form(None),
|
||||
target_date: Optional[str] = Form(None),
|
||||
tags: Optional[str] = Form(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
repo = BaseRepository("projects", db)
|
||||
data = {
|
||||
"name": name, "domain_id": domain_id,
|
||||
"area_id": area_id if area_id and area_id.strip() else None,
|
||||
"description": description, "status": status,
|
||||
"priority": priority,
|
||||
"start_date": start_date if start_date and start_date.strip() else None,
|
||||
"target_date": target_date if target_date and target_date.strip() else None,
|
||||
}
|
||||
if tags and tags.strip():
|
||||
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
|
||||
else:
|
||||
data["tags"] = None
|
||||
|
||||
await repo.update(project_id, data)
|
||||
return RedirectResponse(url=f"/projects/{project_id}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{project_id}/delete")
|
||||
async def delete_project(project_id: str, db: AsyncSession = Depends(get_db)):
|
||||
repo = BaseRepository("projects", db)
|
||||
await repo.soft_delete(project_id)
|
||||
return RedirectResponse(url="/projects", status_code=303)
|
||||
355
routers/tasks.py
Normal file
355
routers/tasks.py
Normal file
@@ -0,0 +1,355 @@
|
||||
"""Tasks: core work items with full filtering and hierarchy."""
|
||||
|
||||
from fastapi import APIRouter, Request, Form, Depends
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
from typing import Optional
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from core.database import get_db
|
||||
from core.base_repository import BaseRepository
|
||||
from core.sidebar import get_sidebar_data
|
||||
|
||||
router = APIRouter(prefix="/tasks", tags=["tasks"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_tasks(
|
||||
request: Request,
|
||||
domain_id: Optional[str] = None,
|
||||
project_id: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
priority: Optional[str] = None,
|
||||
context: Optional[str] = None,
|
||||
sort: str = "sort_order",
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
sidebar = await get_sidebar_data(db)
|
||||
|
||||
where_clauses = ["t.is_deleted = false"]
|
||||
params = {}
|
||||
if domain_id:
|
||||
where_clauses.append("t.domain_id = :domain_id")
|
||||
params["domain_id"] = domain_id
|
||||
if project_id:
|
||||
where_clauses.append("t.project_id = :project_id")
|
||||
params["project_id"] = project_id
|
||||
if status:
|
||||
where_clauses.append("t.status = :status")
|
||||
params["status"] = status
|
||||
if priority:
|
||||
where_clauses.append("t.priority = :priority")
|
||||
params["priority"] = int(priority)
|
||||
if context:
|
||||
where_clauses.append("t.context = :context")
|
||||
params["context"] = context
|
||||
|
||||
where_sql = " AND ".join(where_clauses)
|
||||
|
||||
sort_map = {
|
||||
"sort_order": "t.sort_order, t.created_at",
|
||||
"priority": "t.priority ASC, t.due_date ASC NULLS LAST",
|
||||
"due_date": "t.due_date ASC NULLS LAST, t.priority ASC",
|
||||
"created_at": "t.created_at DESC",
|
||||
"title": "t.title ASC",
|
||||
}
|
||||
order_sql = sort_map.get(sort, sort_map["sort_order"])
|
||||
|
||||
result = await db.execute(text(f"""
|
||||
SELECT t.*,
|
||||
d.name as domain_name, d.color as domain_color,
|
||||
p.name as project_name
|
||||
FROM tasks t
|
||||
LEFT JOIN domains d ON t.domain_id = d.id
|
||||
LEFT JOIN projects p ON t.project_id = p.id
|
||||
WHERE {where_sql}
|
||||
ORDER BY
|
||||
CASE WHEN t.status = 'done' THEN 1 WHEN t.status = 'cancelled' THEN 2 ELSE 0 END,
|
||||
{order_sql}
|
||||
"""), params)
|
||||
items = [dict(r._mapping) for r in result]
|
||||
|
||||
# Get filter options
|
||||
domains_repo = BaseRepository("domains", db)
|
||||
domains = await domains_repo.list()
|
||||
projects_repo = BaseRepository("projects", db)
|
||||
projects = await projects_repo.list()
|
||||
|
||||
result = await db.execute(text(
|
||||
"SELECT value, label FROM context_types WHERE is_deleted = false ORDER BY sort_order"
|
||||
))
|
||||
context_types = [dict(r._mapping) for r in result]
|
||||
|
||||
return templates.TemplateResponse("tasks.html", {
|
||||
"request": request, "sidebar": sidebar, "items": items,
|
||||
"domains": domains, "projects": projects, "context_types": context_types,
|
||||
"current_domain_id": domain_id or "",
|
||||
"current_project_id": project_id or "",
|
||||
"current_status": status or "",
|
||||
"current_priority": priority or "",
|
||||
"current_context": context or "",
|
||||
"current_sort": sort,
|
||||
"page_title": "All Tasks", "active_nav": "tasks",
|
||||
})
|
||||
|
||||
|
||||
@router.get("/create")
|
||||
async def create_form(
|
||||
request: Request,
|
||||
domain_id: Optional[str] = None,
|
||||
project_id: Optional[str] = None,
|
||||
parent_id: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
sidebar = await get_sidebar_data(db)
|
||||
domains_repo = BaseRepository("domains", db)
|
||||
domains = await domains_repo.list()
|
||||
projects_repo = BaseRepository("projects", db)
|
||||
projects = await projects_repo.list()
|
||||
result = await db.execute(text(
|
||||
"SELECT value, label FROM context_types WHERE is_deleted = false ORDER BY sort_order"
|
||||
))
|
||||
context_types = [dict(r._mapping) for r in result]
|
||||
|
||||
return templates.TemplateResponse("task_form.html", {
|
||||
"request": request, "sidebar": sidebar,
|
||||
"domains": domains, "projects": projects, "context_types": context_types,
|
||||
"page_title": "New Task", "active_nav": "tasks",
|
||||
"item": None,
|
||||
"prefill_domain_id": domain_id or "",
|
||||
"prefill_project_id": project_id or "",
|
||||
"prefill_parent_id": parent_id or "",
|
||||
})
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def create_task(
|
||||
request: Request,
|
||||
title: str = Form(...),
|
||||
domain_id: str = Form(...),
|
||||
project_id: Optional[str] = Form(None),
|
||||
parent_id: Optional[str] = Form(None),
|
||||
description: Optional[str] = Form(None),
|
||||
priority: int = Form(3),
|
||||
status: str = Form("open"),
|
||||
due_date: Optional[str] = Form(None),
|
||||
deadline: Optional[str] = Form(None),
|
||||
context: Optional[str] = Form(None),
|
||||
tags: Optional[str] = Form(None),
|
||||
estimated_minutes: Optional[str] = Form(None),
|
||||
energy_required: Optional[str] = Form(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
repo = BaseRepository("tasks", db)
|
||||
data = {
|
||||
"title": title, "domain_id": domain_id,
|
||||
"description": description, "priority": priority, "status": status,
|
||||
}
|
||||
if project_id and project_id.strip():
|
||||
data["project_id"] = project_id
|
||||
if parent_id and parent_id.strip():
|
||||
data["parent_id"] = parent_id
|
||||
if due_date and due_date.strip():
|
||||
data["due_date"] = due_date
|
||||
if deadline and deadline.strip():
|
||||
data["deadline"] = deadline
|
||||
if context and context.strip():
|
||||
data["context"] = context
|
||||
if tags and tags.strip():
|
||||
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
|
||||
if estimated_minutes and estimated_minutes.strip():
|
||||
data["estimated_minutes"] = int(estimated_minutes)
|
||||
if energy_required and energy_required.strip():
|
||||
data["energy_required"] = energy_required
|
||||
|
||||
task = await repo.create(data)
|
||||
|
||||
# Redirect back to project if created from project context
|
||||
if data.get("project_id"):
|
||||
return RedirectResponse(url=f"/projects/{data['project_id']}?tab=tasks", status_code=303)
|
||||
return RedirectResponse(url="/tasks", status_code=303)
|
||||
|
||||
|
||||
@router.get("/{task_id}")
|
||||
async def task_detail(task_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
repo = BaseRepository("tasks", db)
|
||||
sidebar = await get_sidebar_data(db)
|
||||
item = await repo.get(task_id)
|
||||
if not item:
|
||||
return RedirectResponse(url="/tasks", status_code=303)
|
||||
|
||||
# Domain and project info
|
||||
domain = None
|
||||
if item.get("domain_id"):
|
||||
result = await db.execute(text("SELECT name, color FROM domains WHERE id = :id"), {"id": str(item["domain_id"])})
|
||||
row = result.first()
|
||||
domain = dict(row._mapping) if row else None
|
||||
|
||||
project = None
|
||||
if item.get("project_id"):
|
||||
result = await db.execute(text("SELECT id, name FROM projects WHERE id = :id"), {"id": str(item["project_id"])})
|
||||
row = result.first()
|
||||
project = dict(row._mapping) if row else None
|
||||
|
||||
parent = None
|
||||
if item.get("parent_id"):
|
||||
result = await db.execute(text("SELECT id, title FROM tasks WHERE id = :id"), {"id": str(item["parent_id"])})
|
||||
row = result.first()
|
||||
parent = dict(row._mapping) if row else None
|
||||
|
||||
# Subtasks
|
||||
result = await db.execute(text("""
|
||||
SELECT * FROM tasks WHERE parent_id = :tid AND is_deleted = false
|
||||
ORDER BY sort_order, created_at
|
||||
"""), {"tid": task_id})
|
||||
subtasks = [dict(r._mapping) for r in result]
|
||||
|
||||
return templates.TemplateResponse("task_detail.html", {
|
||||
"request": request, "sidebar": sidebar, "item": item,
|
||||
"domain": domain, "project": project, "parent": parent,
|
||||
"subtasks": subtasks,
|
||||
"page_title": item["title"], "active_nav": "tasks",
|
||||
})
|
||||
|
||||
|
||||
@router.get("/{task_id}/edit")
|
||||
async def edit_form(task_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
repo = BaseRepository("tasks", db)
|
||||
sidebar = await get_sidebar_data(db)
|
||||
item = await repo.get(task_id)
|
||||
if not item:
|
||||
return RedirectResponse(url="/tasks", status_code=303)
|
||||
|
||||
domains_repo = BaseRepository("domains", db)
|
||||
domains = await domains_repo.list()
|
||||
projects_repo = BaseRepository("projects", db)
|
||||
projects = await projects_repo.list()
|
||||
result = await db.execute(text(
|
||||
"SELECT value, label FROM context_types WHERE is_deleted = false ORDER BY sort_order"
|
||||
))
|
||||
context_types = [dict(r._mapping) for r in result]
|
||||
|
||||
return templates.TemplateResponse("task_form.html", {
|
||||
"request": request, "sidebar": sidebar,
|
||||
"domains": domains, "projects": projects, "context_types": context_types,
|
||||
"page_title": f"Edit Task", "active_nav": "tasks",
|
||||
"item": item,
|
||||
"prefill_domain_id": "", "prefill_project_id": "", "prefill_parent_id": "",
|
||||
})
|
||||
|
||||
|
||||
@router.post("/{task_id}/edit")
|
||||
async def update_task(
|
||||
task_id: str,
|
||||
title: str = Form(...),
|
||||
domain_id: str = Form(...),
|
||||
project_id: Optional[str] = Form(None),
|
||||
parent_id: Optional[str] = Form(None),
|
||||
description: Optional[str] = Form(None),
|
||||
priority: int = Form(3),
|
||||
status: str = Form("open"),
|
||||
due_date: Optional[str] = Form(None),
|
||||
deadline: Optional[str] = Form(None),
|
||||
context: Optional[str] = Form(None),
|
||||
tags: Optional[str] = Form(None),
|
||||
estimated_minutes: Optional[str] = Form(None),
|
||||
energy_required: Optional[str] = Form(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
repo = BaseRepository("tasks", db)
|
||||
data = {
|
||||
"title": title, "domain_id": domain_id,
|
||||
"description": description, "priority": priority, "status": status,
|
||||
"project_id": project_id if project_id and project_id.strip() else None,
|
||||
"parent_id": parent_id if parent_id and parent_id.strip() else None,
|
||||
"due_date": due_date if due_date and due_date.strip() else None,
|
||||
"deadline": deadline if deadline and deadline.strip() else None,
|
||||
"context": context if context and context.strip() else None,
|
||||
}
|
||||
if tags and tags.strip():
|
||||
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
|
||||
else:
|
||||
data["tags"] = None
|
||||
if estimated_minutes and estimated_minutes.strip():
|
||||
data["estimated_minutes"] = int(estimated_minutes)
|
||||
if energy_required and energy_required.strip():
|
||||
data["energy_required"] = energy_required
|
||||
|
||||
# Handle completion
|
||||
old = await repo.get(task_id)
|
||||
if old and old["status"] != "done" and status == "done":
|
||||
data["completed_at"] = datetime.now(timezone.utc)
|
||||
elif status != "done":
|
||||
data["completed_at"] = None
|
||||
|
||||
await repo.update(task_id, data)
|
||||
return RedirectResponse(url=f"/tasks/{task_id}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{task_id}/complete")
|
||||
async def complete_task(task_id: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Quick complete from list view."""
|
||||
repo = BaseRepository("tasks", db)
|
||||
await repo.update(task_id, {
|
||||
"status": "done",
|
||||
"completed_at": datetime.now(timezone.utc),
|
||||
})
|
||||
return RedirectResponse(url=request.headers.get("referer", "/tasks"), status_code=303)
|
||||
|
||||
|
||||
@router.post("/{task_id}/toggle")
|
||||
async def toggle_task(task_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
"""Toggle task done/open from list view."""
|
||||
repo = BaseRepository("tasks", db)
|
||||
task = await repo.get(task_id)
|
||||
if not task:
|
||||
return RedirectResponse(url="/tasks", status_code=303)
|
||||
|
||||
if task["status"] == "done":
|
||||
await repo.update(task_id, {"status": "open", "completed_at": None})
|
||||
else:
|
||||
await repo.update(task_id, {"status": "done", "completed_at": datetime.now(timezone.utc)})
|
||||
|
||||
referer = request.headers.get("referer", "/tasks")
|
||||
return RedirectResponse(url=referer, status_code=303)
|
||||
|
||||
|
||||
@router.post("/{task_id}/delete")
|
||||
async def delete_task(task_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
repo = BaseRepository("tasks", db)
|
||||
await repo.soft_delete(task_id)
|
||||
referer = request.headers.get("referer", "/tasks")
|
||||
return RedirectResponse(url=referer, status_code=303)
|
||||
|
||||
|
||||
# Quick add from any task list
|
||||
@router.post("/quick-add")
|
||||
async def quick_add(
|
||||
request: Request,
|
||||
title: str = Form(...),
|
||||
domain_id: Optional[str] = Form(None),
|
||||
project_id: Optional[str] = Form(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
repo = BaseRepository("tasks", db)
|
||||
data = {"title": title, "status": "open", "priority": 3}
|
||||
if domain_id and domain_id.strip():
|
||||
data["domain_id"] = domain_id
|
||||
if project_id and project_id.strip():
|
||||
data["project_id"] = project_id
|
||||
|
||||
# If no domain, use first domain
|
||||
if "domain_id" not in data:
|
||||
result = await db.execute(text(
|
||||
"SELECT id FROM domains WHERE is_deleted = false ORDER BY sort_order LIMIT 1"
|
||||
))
|
||||
row = result.first()
|
||||
if row:
|
||||
data["domain_id"] = str(row[0])
|
||||
|
||||
await repo.create(data)
|
||||
referer = request.headers.get("referer", "/tasks")
|
||||
return RedirectResponse(url=referer, status_code=303)
|
||||
56
static/app.js
Normal file
56
static/app.js
Normal file
@@ -0,0 +1,56 @@
|
||||
/* Life OS - UI Interactions */
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Theme toggle
|
||||
const theme = localStorage.getItem('lifeos-theme') || 'dark';
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
|
||||
// Domain tree collapse
|
||||
document.querySelectorAll('.domain-header').forEach(header => {
|
||||
header.addEventListener('click', () => {
|
||||
const children = header.nextElementSibling;
|
||||
if (children && children.classList.contains('domain-children')) {
|
||||
children.classList.toggle('collapsed');
|
||||
const key = 'sidebar-' + header.dataset.domainId;
|
||||
localStorage.setItem(key, children.classList.contains('collapsed'));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Restore collapsed state
|
||||
document.querySelectorAll('.domain-children').forEach(el => {
|
||||
const key = 'sidebar-' + el.dataset.domainId;
|
||||
if (localStorage.getItem(key) === 'true') {
|
||||
el.classList.add('collapsed');
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-submit filters on change
|
||||
document.querySelectorAll('.filter-select[data-auto-submit]').forEach(select => {
|
||||
select.addEventListener('change', () => {
|
||||
select.closest('form').submit();
|
||||
});
|
||||
});
|
||||
|
||||
// Confirm delete dialogs
|
||||
document.querySelectorAll('form[data-confirm]').forEach(form => {
|
||||
form.addEventListener('submit', (e) => {
|
||||
if (!confirm(form.dataset.confirm)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Theme toggle function
|
||||
function toggleTheme() {
|
||||
const current = document.documentElement.getAttribute('data-theme');
|
||||
const next = current === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.setAttribute('data-theme', next);
|
||||
localStorage.setItem('lifeos-theme', next);
|
||||
}
|
||||
|
||||
// Collapsed style
|
||||
document.head.insertAdjacentHTML('beforeend',
|
||||
'<style>.domain-children.collapsed { display: none; }</style>'
|
||||
);
|
||||
845
static/style.css
Normal file
845
static/style.css
Normal file
@@ -0,0 +1,845 @@
|
||||
/* ==========================================================================
|
||||
Life OS - Stylesheet
|
||||
Dark-first design with light theme support.
|
||||
Design tokens from architecture spec Section 9.2.
|
||||
========================================================================== */
|
||||
|
||||
/* ---- Reset ---- */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
/* ---- Theme Tokens ---- */
|
||||
[data-theme="dark"] {
|
||||
--bg: #0D0E13;
|
||||
--surface: #14161F;
|
||||
--surface2: #1A1D28;
|
||||
--surface3: #222533;
|
||||
--border: #252836;
|
||||
--border-light: #2E3244;
|
||||
--text: #DDE1F5;
|
||||
--text-secondary: #9CA3C4;
|
||||
--muted: #5A6080;
|
||||
--accent: #4F6EF7;
|
||||
--accent-hover: #6380FF;
|
||||
--accent-soft: rgba(79,110,247,.12);
|
||||
--green: #22C98A;
|
||||
--green-soft: rgba(34,201,138,.12);
|
||||
--amber: #F5A623;
|
||||
--amber-soft: rgba(245,166,35,.12);
|
||||
--red: #F05252;
|
||||
--red-soft: rgba(240,82,82,.12);
|
||||
--purple: #9B7FF5;
|
||||
--purple-soft: rgba(155,127,245,.12);
|
||||
--shadow: 0 2px 8px rgba(0,0,0,.3);
|
||||
--shadow-lg: 0 8px 32px rgba(0,0,0,.4);
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--bg: #F0F2F8;
|
||||
--surface: #FFFFFF;
|
||||
--surface2: #F7F8FC;
|
||||
--surface3: #EEF0F6;
|
||||
--border: #E3E6F0;
|
||||
--border-light: #ECEEF5;
|
||||
--text: #171926;
|
||||
--text-secondary: #555B78;
|
||||
--muted: #8892B0;
|
||||
--accent: #4F6EF7;
|
||||
--accent-hover: #3A5BE0;
|
||||
--accent-soft: rgba(79,110,247,.10);
|
||||
--green: #10B981;
|
||||
--green-soft: rgba(16,185,129,.10);
|
||||
--amber: #F59E0B;
|
||||
--amber-soft: rgba(245,158,11,.10);
|
||||
--red: #DC2626;
|
||||
--red-soft: rgba(220,38,38,.10);
|
||||
--purple: #7C3AED;
|
||||
--purple-soft: rgba(124,58,237,.10);
|
||||
--shadow: 0 2px 8px rgba(0,0,0,.06);
|
||||
--shadow-lg: 0 8px 32px rgba(0,0,0,.1);
|
||||
}
|
||||
|
||||
/* ---- Typography ---- */
|
||||
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=JetBrains+Mono:wght@400;500&display=swap');
|
||||
|
||||
:root {
|
||||
--font-body: 'DM Sans', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
--radius: 8px;
|
||||
--radius-sm: 4px;
|
||||
--radius-lg: 12px;
|
||||
--sidebar-width: 260px;
|
||||
--topbar-height: 52px;
|
||||
--transition: 150ms ease;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 14px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-body);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { color: var(--accent-hover); }
|
||||
|
||||
/* ---- Layout Shell ---- */
|
||||
.app-layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ---- Sidebar ---- */
|
||||
.sidebar {
|
||||
width: var(--sidebar-width);
|
||||
background: var(--surface);
|
||||
border-right: 1px solid var(--border);
|
||||
height: 100vh;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 16px 16px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.sidebar-logo span {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
padding: 8px 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.nav-section {
|
||||
padding: 0 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.nav-section-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
padding: 12px 8px 4px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.92rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: var(--accent-soft);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.nav-item .badge {
|
||||
margin-left: auto;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
min-width: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
opacity: 0.7;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-item.active .nav-icon { opacity: 1; }
|
||||
|
||||
/* Domain tree */
|
||||
.domain-group { margin-top: 4px; }
|
||||
|
||||
.domain-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.domain-header:hover { background: var(--surface2); }
|
||||
|
||||
.domain-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.domain-children {
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
.area-label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
padding: 6px 10px 2px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.project-link {
|
||||
display: block;
|
||||
padding: 4px 10px 4px 18px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.project-link:hover {
|
||||
background: var(--surface2);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* ---- Main Content ---- */
|
||||
.main-content {
|
||||
margin-left: var(--sidebar-width);
|
||||
flex: 1;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
height: var(--topbar-height);
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 24px;
|
||||
gap: 16px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.topbar-env {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
background: var(--amber-soft);
|
||||
color: var(--amber);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.topbar-spacer { flex: 1; }
|
||||
|
||||
.page-content {
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
max-width: 1200px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ---- Page Header ---- */
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.page-count {
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
margin-left: 8px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* ---- Breadcrumb ---- */
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.82rem;
|
||||
color: var(--muted);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.breadcrumb a { color: var(--text-secondary); }
|
||||
.breadcrumb a:hover { color: var(--accent); }
|
||||
.breadcrumb .sep { color: var(--border); }
|
||||
|
||||
/* ---- Buttons ---- */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius);
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
text-decoration: none;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-primary:hover { background: var(--accent-hover); color: #fff; }
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--surface2);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.btn-secondary:hover { background: var(--surface3); color: var(--text); }
|
||||
|
||||
.btn-danger {
|
||||
background: var(--red-soft);
|
||||
color: var(--red);
|
||||
}
|
||||
.btn-danger:hover { background: var(--red); color: #fff; }
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
padding: 6px 10px;
|
||||
}
|
||||
.btn-ghost:hover { background: var(--surface2); color: var(--text); }
|
||||
|
||||
.btn-sm { padding: 5px 10px; font-size: 0.8rem; }
|
||||
.btn-xs { padding: 3px 8px; font-size: 0.75rem; }
|
||||
|
||||
/* ---- Cards ---- */
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ---- Tables / List Rows ---- */
|
||||
.list-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.list-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
transition: background var(--transition);
|
||||
}
|
||||
|
||||
.list-row:hover {
|
||||
background: var(--surface2);
|
||||
}
|
||||
|
||||
.list-row.completed .row-title {
|
||||
text-decoration: line-through;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.row-check {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.row-check input[type="checkbox"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row-check label {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--border-light);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.row-check label:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.row-check input[type="checkbox"]:checked + label {
|
||||
background: var(--green);
|
||||
border-color: var(--green);
|
||||
}
|
||||
|
||||
.row-check input[type="checkbox"]:checked + label::after {
|
||||
content: "\2713";
|
||||
color: #fff;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.priority-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.priority-1 { background: var(--red); }
|
||||
.priority-2 { background: var(--amber); }
|
||||
.priority-3 { background: var(--accent); }
|
||||
.priority-4 { background: var(--muted); }
|
||||
|
||||
.row-title {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row-title a { color: var(--text); }
|
||||
.row-title a:hover { color: var(--accent); }
|
||||
|
||||
.row-meta {
|
||||
font-size: 0.78rem;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row-tag {
|
||||
font-size: 0.72rem;
|
||||
background: var(--surface2);
|
||||
color: var(--text-secondary);
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.row-domain-tag {
|
||||
font-size: 0.72rem;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition);
|
||||
}
|
||||
|
||||
.list-row:hover .row-actions { opacity: 1; }
|
||||
|
||||
.overdue { color: var(--red) !important; font-weight: 600; }
|
||||
|
||||
/* ---- Filters Bar ---- */
|
||||
.filters-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.82rem;
|
||||
padding: 6px 10px;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filter-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ---- Forms ---- */
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.form-group.full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-select,
|
||||
.form-textarea {
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.92rem;
|
||||
padding: 9px 12px;
|
||||
background: var(--surface2);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
transition: border-color var(--transition);
|
||||
}
|
||||
|
||||
.form-input:focus,
|
||||
.form-select:focus,
|
||||
.form-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
min-height: 120px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
/* ---- Quick Add ---- */
|
||||
.quick-add {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.quick-add input {
|
||||
flex: 1;
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.92rem;
|
||||
padding: 9px 12px;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.quick-add input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.quick-add input::placeholder { color: var(--muted); }
|
||||
|
||||
/* ---- Progress Bar ---- */
|
||||
.progress-bar {
|
||||
height: 6px;
|
||||
background: var(--surface2);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--green);
|
||||
border-radius: 3px;
|
||||
transition: width 300ms ease;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 0.78rem;
|
||||
color: var(--muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ---- Tabs ---- */
|
||||
.tab-strip {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
padding: 10px 20px;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tab-item:hover { color: var(--text); }
|
||||
|
||||
.tab-item.active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ---- Status Badges ---- */
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: 3px 10px;
|
||||
border-radius: 10px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.status-open { background: var(--accent-soft); color: var(--accent); }
|
||||
.status-active { background: var(--green-soft); color: var(--green); }
|
||||
.status-in_progress { background: var(--amber-soft); color: var(--amber); }
|
||||
.status-blocked { background: var(--red-soft); color: var(--red); }
|
||||
.status-done { background: var(--green-soft); color: var(--green); }
|
||||
.status-completed { background: var(--green-soft); color: var(--green); }
|
||||
.status-cancelled { background: var(--surface2); color: var(--muted); }
|
||||
.status-on_hold { background: var(--purple-soft); color: var(--purple); }
|
||||
.status-archived { background: var(--surface2); color: var(--muted); }
|
||||
|
||||
/* ---- Empty State ---- */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 48px 24px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 12px;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.empty-state-text {
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* ---- Detail Header ---- */
|
||||
.detail-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.detail-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.detail-meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.detail-body {
|
||||
line-height: 1.75;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.detail-body p { margin-bottom: 1em; }
|
||||
.detail-body h1, .detail-body h2, .detail-body h3 { margin: 1.2em 0 0.6em; font-weight: 600; }
|
||||
.detail-body code { font-family: var(--font-mono); background: var(--surface2); padding: 1px 5px; border-radius: 3px; }
|
||||
.detail-body pre { background: var(--surface2); padding: 16px; border-radius: var(--radius); overflow-x: auto; margin: 1em 0; }
|
||||
.detail-body pre code { background: none; padding: 0; }
|
||||
|
||||
/* ---- Dashboard Widgets ---- */
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.82rem;
|
||||
color: var(--muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ---- Capture Items ---- */
|
||||
.capture-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.capture-text {
|
||||
flex: 1;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.capture-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ---- Focus Items ---- */
|
||||
.focus-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 6px;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.focus-item:hover { border-color: var(--accent); }
|
||||
.focus-item.completed { opacity: 0.6; }
|
||||
.focus-item.completed .focus-title { text-decoration: line-through; }
|
||||
|
||||
.focus-title { flex: 1; font-weight: 500; }
|
||||
.focus-meta { font-size: 0.78rem; color: var(--muted); }
|
||||
|
||||
/* ---- Utility ---- */
|
||||
.text-muted { color: var(--muted); }
|
||||
.text-sm { font-size: 0.82rem; }
|
||||
.text-xs { font-size: 0.75rem; }
|
||||
.mt-1 { margin-top: 4px; }
|
||||
.mt-2 { margin-top: 8px; }
|
||||
.mt-3 { margin-top: 16px; }
|
||||
.mt-4 { margin-top: 24px; }
|
||||
.mb-2 { margin-bottom: 8px; }
|
||||
.mb-3 { margin-bottom: 16px; }
|
||||
.mb-4 { margin-bottom: 24px; }
|
||||
.gap-2 { gap: 8px; }
|
||||
.flex { display: flex; }
|
||||
.flex-col { flex-direction: column; }
|
||||
.items-center { align-items: center; }
|
||||
.justify-between { justify-content: space-between; }
|
||||
.flex-1 { flex: 1; }
|
||||
.hidden { display: none; }
|
||||
|
||||
/* ---- Scrollbar ---- */
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--muted); }
|
||||
|
||||
/* ---- Responsive ---- */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar { display: none; }
|
||||
.main-content { margin-left: 0; }
|
||||
.form-grid { grid-template-columns: 1fr; }
|
||||
.dashboard-grid { grid-template-columns: 1fr; }
|
||||
.page-content { padding: 16px; }
|
||||
}
|
||||
22
templates/area_form.html
Normal file
22
templates/area_form.html
Normal file
@@ -0,0 +1,22 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="breadcrumb"><a href="/areas">Areas</a><span class="sep">/</span><span>{{ 'Edit' if item else 'New Area' }}</span></div>
|
||||
<div class="page-header"><h1 class="page-title">{{ 'Edit Area' if item else 'New Area' }}</h1></div>
|
||||
<div class="card">
|
||||
<form method="post" action="{{ '/areas/' ~ item.id ~ '/edit' if item else '/areas/create' }}">
|
||||
<div class="form-grid">
|
||||
<div class="form-group"><label class="form-label">Name *</label><input type="text" name="name" class="form-input" required value="{{ item.name if item else '' }}"></div>
|
||||
<div class="form-group"><label class="form-label">Domain *</label>
|
||||
<select name="domain_id" class="form-select" required>
|
||||
{% for d in domains %}<option value="{{ d.id }}" {{ 'selected' if (item and item.domain_id|string == d.id|string) or (not item and prefill_domain_id == d.id|string) }}>{{ d.name }}</option>{% endfor %}
|
||||
</select></div>
|
||||
<div class="form-group"><label class="form-label">Status</label>
|
||||
<select name="status" class="form-select">
|
||||
<option value="active" {{ 'selected' if not item or item.status == 'active' }}>Active</option>
|
||||
<option value="inactive" {{ 'selected' if item and item.status == 'inactive' }}>Inactive</option>
|
||||
</select></div>
|
||||
<div class="form-group full-width"><label class="form-label">Description</label><textarea name="description" class="form-textarea" rows="3">{{ item.description if item and item.description else '' }}</textarea></div>
|
||||
<div class="form-actions"><button type="submit" class="btn btn-primary">{{ 'Save' if item else 'Create' }}</button><a href="/areas" class="btn btn-secondary">Cancel</a></div>
|
||||
</div>
|
||||
</form></div>
|
||||
{% endblock %}
|
||||
25
templates/areas.html
Normal file
25
templates/areas.html
Normal file
@@ -0,0 +1,25 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Areas<span class="page-count">{{ items|length }}</span></h1>
|
||||
<a href="/areas/create" class="btn btn-primary">+ New Area</a>
|
||||
</div>
|
||||
{% if items %}
|
||||
<div class="card">
|
||||
{% for item in items %}
|
||||
<div class="list-row">
|
||||
<span class="row-domain-tag" style="background:{{ item.domain_color or '#4F6EF7' }}22;color:{{ item.domain_color or '#4F6EF7' }}">{{ item.domain_name }}</span>
|
||||
<span class="row-title">{{ item.name }}</span>
|
||||
{% if item.description %}<span class="row-meta">{{ item.description[:60] }}</span>{% endif %}
|
||||
<span class="status-badge status-{{ item.status }}">{{ item.status }}</span>
|
||||
<div class="row-actions">
|
||||
<a href="/areas/{{ item.id }}/edit" class="btn btn-ghost btn-xs">Edit</a>
|
||||
<form action="/areas/{{ item.id }}/delete" method="post" data-confirm="Delete this area?" style="display:inline"><button class="btn btn-ghost btn-xs" style="color:var(--red)">Del</button></form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state"><div class="empty-state-text">No areas yet</div><a href="/areas/create" class="btn btn-primary">Create Area</a></div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
111
templates/base.html
Normal file
111
templates/base.html
Normal file
@@ -0,0 +1,111 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ page_title or "Life OS" }} - Life OS</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-layout">
|
||||
<!-- Sidebar -->
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<a href="/" class="sidebar-logo">Life<span>OS</span></a>
|
||||
</div>
|
||||
<nav class="sidebar-nav">
|
||||
<div class="nav-section">
|
||||
<a href="/" class="nav-item {{ 'active' if active_nav == 'dashboard' }}">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
|
||||
Dashboard
|
||||
</a>
|
||||
<a href="/focus" class="nav-item {{ 'active' if active_nav == 'focus' }}">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>
|
||||
Focus
|
||||
{% if sidebar.focus_count %}<span class="badge">{{ sidebar.focus_count }}</span>{% endif %}
|
||||
</a>
|
||||
<a href="/tasks" class="nav-item {{ 'active' if active_nav == 'tasks' }}">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
|
||||
All Tasks
|
||||
</a>
|
||||
<a href="/projects" class="nav-item {{ 'active' if active_nav == 'projects' }}">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
||||
Projects
|
||||
</a>
|
||||
<a href="/notes" class="nav-item {{ 'active' if active_nav == 'notes' }}">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>
|
||||
Notes
|
||||
</a>
|
||||
<a href="/contacts" class="nav-item {{ 'active' if active_nav == 'contacts' }}">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||
Contacts
|
||||
</a>
|
||||
<a href="/links" class="nav-item {{ 'active' if active_nav == 'links' }}">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
||||
Links
|
||||
</a>
|
||||
<a href="/capture" class="nav-item {{ 'active' if active_nav == 'capture' }}">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>
|
||||
Capture
|
||||
{% if sidebar.capture_count %}<span class="badge">{{ sidebar.capture_count }}</span>{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Domain hierarchy -->
|
||||
<div class="nav-section">
|
||||
<div class="nav-section-label">Domains</div>
|
||||
{% for domain in sidebar.domain_tree %}
|
||||
<div class="domain-group">
|
||||
<div class="domain-header" data-domain-id="{{ domain.id }}">
|
||||
<span class="domain-dot" style="background: {{ domain.color or '#4F6EF7' }}"></span>
|
||||
{{ domain.name }}
|
||||
</div>
|
||||
<div class="domain-children" data-domain-id="{{ domain.id }}">
|
||||
{% for area in domain.areas %}
|
||||
<div class="area-label">{{ area.name }}</div>
|
||||
{% for p in area.projects %}
|
||||
<a href="/projects/{{ p.id }}" class="project-link">{{ p.name }}</a>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
{% for p in domain.standalone_projects %}
|
||||
<a href="/projects/{{ p.id }}" class="project-link">{{ p.name }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="nav-section" style="margin-top: auto; padding-bottom: 12px;">
|
||||
<a href="/domains" class="nav-item {{ 'active' if active_nav == 'domains' }}">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M12 1v6m0 6v6m-7-7h6m6 0h6"/></svg>
|
||||
Manage Domains
|
||||
</a>
|
||||
<a href="/areas" class="nav-item {{ 'active' if active_nav == 'areas' }}">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg>
|
||||
Manage Areas
|
||||
</a>
|
||||
<div class="nav-item" onclick="toggleTheme()" style="cursor: pointer;">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
||||
Toggle Theme
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- Main content -->
|
||||
<main class="main-content">
|
||||
<header class="topbar">
|
||||
{% if request.state.environment == 'development' %}
|
||||
<span class="topbar-env">DEV</span>
|
||||
{% endif %}
|
||||
<div class="topbar-spacer"></div>
|
||||
</header>
|
||||
|
||||
<div class="page-content">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
42
templates/capture.html
Normal file
42
templates/capture.html
Normal file
@@ -0,0 +1,42 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Capture<span class="page-count">{{ items|length }}</span></h1>
|
||||
</div>
|
||||
|
||||
<!-- Quick capture input -->
|
||||
<div class="card mb-4">
|
||||
<form action="/capture/add" method="post">
|
||||
<label class="form-label mb-2">Quick Capture (one item per line)</label>
|
||||
<textarea name="raw_text" class="form-textarea" rows="3" placeholder="Type or paste items here... Each line becomes a separate capture item"></textarea>
|
||||
<div class="mt-2"><button type="submit" class="btn btn-primary">Capture</button></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<a href="/capture?show=unprocessed" class="btn {{ 'btn-primary' if show != 'all' else 'btn-secondary' }} btn-sm">Unprocessed</a>
|
||||
<a href="/capture?show=all" class="btn {{ 'btn-primary' if show == 'all' else 'btn-secondary' }} btn-sm">All</a>
|
||||
</div>
|
||||
|
||||
{% if items %}
|
||||
{% for item in items %}
|
||||
<div class="capture-item {{ 'completed' if item.processed }}">
|
||||
<div class="capture-text {{ 'text-muted' if item.processed }}" style="{{ 'text-decoration:line-through' if item.processed }}">{{ item.raw_text }}</div>
|
||||
{% if not item.processed %}
|
||||
<div class="capture-actions">
|
||||
<form action="/capture/{{ item.id }}/to-task" method="post" class="flex gap-2">
|
||||
<select name="domain_id" class="filter-select" style="font-size:0.75rem;padding:3px 6px" required>
|
||||
{% for d in sidebar.domain_tree %}<option value="{{ d.id }}">{{ d.name }}</option>{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="btn btn-ghost btn-xs" style="color:var(--green)">To Task</button>
|
||||
</form>
|
||||
<form action="/capture/{{ item.id }}/dismiss" method="post" style="display:inline"><button class="btn btn-ghost btn-xs">Dismiss</button></form>
|
||||
<form action="/capture/{{ item.id }}/delete" method="post" style="display:inline"><button class="btn btn-ghost btn-xs" style="color:var(--red)">×</button></form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="empty-state"><div class="empty-state-icon">📥</div><div class="empty-state-text">Capture queue is empty</div></div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
25
templates/contact_detail.html
Normal file
25
templates/contact_detail.html
Normal file
@@ -0,0 +1,25 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="breadcrumb"><a href="/contacts">Contacts</a><span class="sep">/</span><span>{{ item.first_name }} {{ item.last_name or '' }}</span></div>
|
||||
<div class="detail-header">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="detail-title">{{ item.first_name }} {{ item.last_name or '' }}</h1>
|
||||
<div class="flex gap-2">
|
||||
<a href="/contacts/{{ item.id }}/edit" class="btn btn-secondary btn-sm">Edit</a>
|
||||
<form action="/contacts/{{ item.id }}/delete" method="post" data-confirm="Delete?" style="display:inline"><button class="btn btn-danger btn-sm">Delete</button></form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-meta mt-2">
|
||||
{% if item.company %}<span class="row-tag">{{ item.company }}</span>{% endif %}
|
||||
{% if item.role %}<span class="detail-meta-item">{{ item.role }}</span>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="form-grid" style="gap:12px">
|
||||
{% if item.email %}<div class="form-group"><div class="form-label">Email</div><a href="mailto:{{ item.email }}">{{ item.email }}</a></div>{% endif %}
|
||||
{% if item.phone %}<div class="form-group"><div class="form-label">Phone</div><a href="tel:{{ item.phone }}">{{ item.phone }}</a></div>{% endif %}
|
||||
{% if item.notes %}<div class="form-group full-width"><div class="form-label">Notes</div><div class="detail-body" style="white-space:pre-wrap">{{ item.notes }}</div></div>{% endif %}
|
||||
{% if item.tags %}<div class="form-group full-width"><div class="form-label">Tags</div><div class="flex gap-2">{% for tag in item.tags %}<span class="row-tag">{{ tag }}</span>{% endfor %}</div></div>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
19
templates/contact_form.html
Normal file
19
templates/contact_form.html
Normal file
@@ -0,0 +1,19 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="breadcrumb"><a href="/contacts">Contacts</a><span class="sep">/</span><span>{{ 'Edit' if item else 'New Contact' }}</span></div>
|
||||
<div class="page-header"><h1 class="page-title">{{ 'Edit Contact' if item else 'New Contact' }}</h1></div>
|
||||
<div class="card">
|
||||
<form method="post" action="{{ '/contacts/' ~ item.id ~ '/edit' if item else '/contacts/create' }}">
|
||||
<div class="form-grid">
|
||||
<div class="form-group"><label class="form-label">First Name *</label><input type="text" name="first_name" class="form-input" required value="{{ item.first_name if item else '' }}"></div>
|
||||
<div class="form-group"><label class="form-label">Last Name</label><input type="text" name="last_name" class="form-input" value="{{ item.last_name if item and item.last_name else '' }}"></div>
|
||||
<div class="form-group"><label class="form-label">Company</label><input type="text" name="company" class="form-input" value="{{ item.company if item and item.company else '' }}"></div>
|
||||
<div class="form-group"><label class="form-label">Role</label><input type="text" name="role" class="form-input" value="{{ item.role if item and item.role else '' }}"></div>
|
||||
<div class="form-group"><label class="form-label">Email</label><input type="email" name="email" class="form-input" value="{{ item.email if item and item.email else '' }}"></div>
|
||||
<div class="form-group"><label class="form-label">Phone</label><input type="tel" name="phone" class="form-input" value="{{ item.phone if item and item.phone else '' }}"></div>
|
||||
<div class="form-group full-width"><label class="form-label">Notes</label><textarea name="notes" class="form-textarea" rows="4">{{ item.notes if item and item.notes else '' }}</textarea></div>
|
||||
<div class="form-group full-width"><label class="form-label">Tags</label><input type="text" name="tags" class="form-input" value="{{ item.tags|join(', ') if item and item.tags else '' }}"></div>
|
||||
<div class="form-actions"><button type="submit" class="btn btn-primary">{{ 'Save' if item else 'Create' }}</button><a href="/contacts" class="btn btn-secondary">Cancel</a></div>
|
||||
</div>
|
||||
</form></div>
|
||||
{% endblock %}
|
||||
25
templates/contacts.html
Normal file
25
templates/contacts.html
Normal file
@@ -0,0 +1,25 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Contacts<span class="page-count">{{ items|length }}</span></h1>
|
||||
<a href="/contacts/create" class="btn btn-primary">+ New Contact</a>
|
||||
</div>
|
||||
{% if items %}
|
||||
<div class="card">
|
||||
{% for item in items %}
|
||||
<div class="list-row">
|
||||
<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.role %}<span class="row-meta">{{ item.role }}</span>{% endif %}
|
||||
{% if item.email %}<span class="row-meta">{{ item.email }}</span>{% endif %}
|
||||
<div class="row-actions">
|
||||
<a href="/contacts/{{ item.id }}/edit" class="btn btn-ghost btn-xs">Edit</a>
|
||||
<form action="/contacts/{{ item.id }}/delete" method="post" data-confirm="Delete?" style="display:inline"><button class="btn btn-ghost btn-xs" style="color:var(--red)">Del</button></form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state"><div class="empty-state-icon">👤</div><div class="empty-state-text">No contacts yet</div><a href="/contacts/create" class="btn btn-primary">Add Contact</a></div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
86
templates/dashboard.html
Normal file
86
templates/dashboard.html
Normal file
@@ -0,0 +1,86 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Dashboard</h1>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="dashboard-grid mb-4">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ stats.open_tasks or 0 }}</div>
|
||||
<div class="stat-label">Open Tasks</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ stats.in_progress or 0 }}</div>
|
||||
<div class="stat-label">In Progress</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ stats.done_this_week or 0 }}</div>
|
||||
<div class="stat-label">Done This Week</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ focus_items|length }}</div>
|
||||
<div class="stat-label">Today's Focus</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-grid">
|
||||
<!-- Today's Focus -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">Today's Focus</h2>
|
||||
<a href="/focus" class="btn btn-ghost btn-sm">View All</a>
|
||||
</div>
|
||||
{% if focus_items %}
|
||||
{% for item in focus_items %}
|
||||
<div class="focus-item {{ 'completed' if item.completed }}">
|
||||
<span class="priority-dot priority-{{ item.priority }}"></span>
|
||||
<a href="/tasks/{{ item.task_id }}" class="focus-title">{{ item.title }}</a>
|
||||
{% if item.project_name %}
|
||||
<span class="row-meta">{{ item.project_name }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-text">No focus items for today</div>
|
||||
<a href="/focus" class="btn btn-primary btn-sm">Set Focus</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Overdue + Upcoming -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">Upcoming</h2>
|
||||
</div>
|
||||
{% if overdue_tasks %}
|
||||
<div class="text-xs text-muted mb-2" style="font-weight:600; color: var(--red);">OVERDUE</div>
|
||||
{% for t in overdue_tasks %}
|
||||
<div class="list-row">
|
||||
<span class="priority-dot priority-{{ t.priority }}"></span>
|
||||
<span class="row-title"><a href="/tasks/{{ t.id }}">{{ t.title }}</a></span>
|
||||
<span class="row-meta overdue">{{ t.due_date }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if upcoming_tasks %}
|
||||
<div class="text-xs text-muted mb-2 {{ 'mt-3' if overdue_tasks }}" style="font-weight:600;">NEXT 7 DAYS</div>
|
||||
{% for t in upcoming_tasks %}
|
||||
<div class="list-row">
|
||||
<span class="priority-dot priority-{{ t.priority }}"></span>
|
||||
<span class="row-title"><a href="/tasks/{{ t.id }}">{{ t.title }}</a></span>
|
||||
<span class="row-meta">{{ t.due_date }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if not overdue_tasks and not upcoming_tasks %}
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-text">No upcoming deadlines</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
17
templates/domain_form.html
Normal file
17
templates/domain_form.html
Normal file
@@ -0,0 +1,17 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="breadcrumb"><a href="/domains">Domains</a><span class="sep">/</span><span>{{ 'Edit' if item else 'New Domain' }}</span></div>
|
||||
<div class="page-header"><h1 class="page-title">{{ 'Edit Domain' if item else 'New Domain' }}</h1></div>
|
||||
<div class="card">
|
||||
<form method="post" action="{{ '/domains/' ~ item.id ~ '/edit' if item else '/domains/create' }}">
|
||||
<div class="form-grid">
|
||||
<div class="form-group"><label class="form-label">Name *</label><input type="text" name="name" class="form-input" required value="{{ item.name if item else '' }}"></div>
|
||||
<div class="form-group"><label class="form-label">Color</label><input type="color" name="color" class="form-input" value="{{ item.color if item and item.color else '#4F6EF7' }}" style="height:42px;padding:4px"></div>
|
||||
<div class="form-group full-width"><label class="form-label">Description</label><textarea name="description" class="form-textarea" rows="3">{{ item.description if item and item.description else '' }}</textarea></div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">{{ 'Save' if item else 'Create' }}</button>
|
||||
<a href="/domains" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
</form></div>
|
||||
{% endblock %}
|
||||
26
templates/domains.html
Normal file
26
templates/domains.html
Normal file
@@ -0,0 +1,26 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Domains<span class="page-count">{{ items|length }}</span></h1>
|
||||
<a href="/domains/create" class="btn btn-primary">+ New Domain</a>
|
||||
</div>
|
||||
{% if items %}
|
||||
<div class="card">
|
||||
{% for item in items %}
|
||||
<div class="list-row">
|
||||
<span class="domain-dot" style="background: {{ item.color or '#4F6EF7' }}"></span>
|
||||
<span class="row-title"><a href="/tasks?domain_id={{ item.id }}">{{ item.name }}</a></span>
|
||||
{% if item.description %}<span class="row-meta">{{ item.description }}</span>{% endif %}
|
||||
<div class="row-actions">
|
||||
<a href="/domains/{{ item.id }}/edit" class="btn btn-ghost btn-xs">Edit</a>
|
||||
<form action="/domains/{{ item.id }}/delete" method="post" data-confirm="Delete this domain and all its contents?" style="display:inline">
|
||||
<button class="btn btn-ghost btn-xs" style="color:var(--red)">Del</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state"><div class="empty-state-text">No domains. Create your first life domain.</div><a href="/domains/create" class="btn btn-primary">Create Domain</a></div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
58
templates/focus.html
Normal file
58
templates/focus.html
Normal file
@@ -0,0 +1,58 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Daily Focus</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
{% if total_estimated %}<span class="text-sm text-muted">~{{ total_estimated }}min estimated</span>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<a href="/focus?focus_date={{ (focus_date|string)[:10] }}" class="btn btn-ghost btn-sm">Today</a>
|
||||
<span class="text-sm" style="font-weight:600">{{ focus_date }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Focus items -->
|
||||
{% if items %}
|
||||
{% for item in items %}
|
||||
<div class="focus-item {{ 'completed' if item.completed }}">
|
||||
<form action="/focus/{{ item.id }}/toggle" method="post" style="display:inline">
|
||||
<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>
|
||||
</form>
|
||||
<span class="priority-dot priority-{{ item.priority }}"></span>
|
||||
<a href="/tasks/{{ item.task_id }}" class="focus-title">{{ item.title }}</a>
|
||||
{% if item.project_name %}<span class="row-tag">{{ item.project_name }}</span>{% endif %}
|
||||
{% if item.estimated_minutes %}<span class="focus-meta">~{{ item.estimated_minutes }}min</span>{% endif %}
|
||||
{% if item.due_date %}<span class="focus-meta">{{ item.due_date }}</span>{% endif %}
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="empty-state mb-4"><div class="empty-state-text">No focus items for this day</div></div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Add task to focus -->
|
||||
{% if available_tasks %}
|
||||
<div class="card mt-4">
|
||||
<div class="card-header"><h2 class="card-title">Add to Focus</h2></div>
|
||||
{% for t in available_tasks[:15] %}
|
||||
<div class="list-row">
|
||||
<span class="priority-dot priority-{{ t.priority }}"></span>
|
||||
<span class="row-title">{{ t.title }}</span>
|
||||
{% if t.project_name %}<span class="row-tag">{{ t.project_name }}</span>{% endif %}
|
||||
{% if t.due_date %}<span class="row-meta">{{ t.due_date }}</span>{% endif %}
|
||||
<form action="/focus/add" method="post" style="display:inline">
|
||||
<input type="hidden" name="task_id" value="{{ t.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>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
18
templates/link_form.html
Normal file
18
templates/link_form.html
Normal file
@@ -0,0 +1,18 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="breadcrumb"><a href="/links">Links</a><span class="sep">/</span><span>{{ 'Edit' if item else 'New Link' }}</span></div>
|
||||
<div class="page-header"><h1 class="page-title">{{ 'Edit Link' if item else 'New Link' }}</h1></div>
|
||||
<div class="card">
|
||||
<form method="post" action="{{ '/links/' ~ item.id ~ '/edit' if item else '/links/create' }}">
|
||||
<div class="form-grid">
|
||||
<div class="form-group"><label class="form-label">Label *</label><input type="text" name="label" class="form-input" required value="{{ item.label if item else '' }}"></div>
|
||||
<div class="form-group"><label class="form-label">URL *</label><input type="url" name="url" class="form-input" required value="{{ item.url if item else '' }}"></div>
|
||||
<div class="form-group"><label class="form-label">Domain *</label>
|
||||
<select name="domain_id" class="form-select" required>{% for d in domains %}<option value="{{ d.id }}" {{ 'selected' if (item and item.domain_id|string == d.id|string) or (not item and prefill_domain_id == d.id|string) }}>{{ d.name }}</option>{% endfor %}</select></div>
|
||||
<div class="form-group"><label class="form-label">Project</label>
|
||||
<select name="project_id" class="form-select"><option value="">-- None --</option>{% for p in projects %}<option value="{{ p.id }}" {{ 'selected' if (item and item.project_id and item.project_id|string == p.id|string) or (not item and prefill_project_id == p.id|string) }}>{{ p.name }}</option>{% endfor %}</select></div>
|
||||
<div class="form-group full-width"><label class="form-label">Description</label><textarea name="description" class="form-textarea" rows="3">{{ item.description if item and item.description else '' }}</textarea></div>
|
||||
<div class="form-actions"><button type="submit" class="btn btn-primary">{{ 'Save' if item else 'Create' }}</button><a href="/links" class="btn btn-secondary">Cancel</a></div>
|
||||
</div>
|
||||
</form></div>
|
||||
{% endblock %}
|
||||
25
templates/links.html
Normal file
25
templates/links.html
Normal file
@@ -0,0 +1,25 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Links<span class="page-count">{{ items|length }}</span></h1>
|
||||
<a href="/links/create" class="btn btn-primary">+ New Link</a>
|
||||
</div>
|
||||
{% if items %}
|
||||
<div class="card">
|
||||
{% for item in items %}
|
||||
<div class="list-row">
|
||||
{% 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>
|
||||
{% if item.project_name %}<span class="row-tag">{{ item.project_name }}</span>{% endif %}
|
||||
<span class="row-meta">{{ item.url[:50] }}{% if item.url|length > 50 %}...{% endif %}</span>
|
||||
<div class="row-actions">
|
||||
<a href="/links/{{ item.id }}/edit" class="btn btn-ghost btn-xs">Edit</a>
|
||||
<form action="/links/{{ item.id }}/delete" method="post" data-confirm="Delete?" style="display:inline"><button class="btn btn-ghost btn-xs" style="color:var(--red)">Del</button></form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state"><div class="empty-state-icon">🔗</div><div class="empty-state-text">No links yet</div><a href="/links/create" class="btn btn-primary">Add Link</a></div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
26
templates/note_detail.html
Normal file
26
templates/note_detail.html
Normal file
@@ -0,0 +1,26 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="breadcrumb">
|
||||
{% if domain %}<span style="color:{{ domain.color }}">{{ domain.name }}</span><span class="sep">/</span>{% endif %}
|
||||
{% if project %}<a href="/projects/{{ project.id }}">{{ project.name }}</a><span class="sep">/</span>{% endif %}
|
||||
<span>{{ item.title }}</span>
|
||||
</div>
|
||||
<div class="detail-header">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="detail-title">{{ item.title }}</h1>
|
||||
<div class="flex gap-2">
|
||||
<a href="/notes/{{ item.id }}/edit" class="btn btn-secondary btn-sm">Edit</a>
|
||||
<form action="/notes/{{ item.id }}/delete" method="post" data-confirm="Delete?" style="display:inline"><button class="btn btn-danger btn-sm">Delete</button></form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-meta mt-2">
|
||||
<span class="detail-meta-item">Updated {{ item.updated_at.strftime('%Y-%m-%d %H:%M') if item.updated_at else '' }}</span>
|
||||
{% if item.tags %}{% for tag in item.tags %}<span class="row-tag">{{ tag }}</span>{% endfor %}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if item.body %}
|
||||
<div class="card"><div class="detail-body" style="white-space:pre-wrap;font-family:var(--font-body)">{{ item.body }}</div></div>
|
||||
{% 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>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
19
templates/note_form.html
Normal file
19
templates/note_form.html
Normal file
@@ -0,0 +1,19 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="breadcrumb"><a href="/notes">Notes</a><span class="sep">/</span><span>{{ 'Edit' if item else 'New Note' }}</span></div>
|
||||
<div class="page-header"><h1 class="page-title">{{ 'Edit Note' if item else 'New Note' }}</h1></div>
|
||||
<div class="card">
|
||||
<form method="post" action="{{ '/notes/' ~ item.id ~ '/edit' if item else '/notes/create' }}">
|
||||
<div class="form-grid">
|
||||
<div class="form-group full-width"><label class="form-label">Title *</label><input type="text" name="title" class="form-input" required value="{{ item.title if item else '' }}"></div>
|
||||
<div class="form-group"><label class="form-label">Domain *</label>
|
||||
<select name="domain_id" class="form-select" required>{% for d in domains %}<option value="{{ d.id }}" {{ 'selected' if (item and item.domain_id|string == d.id|string) or (not item and prefill_domain_id == d.id|string) }}>{{ d.name }}</option>{% endfor %}</select></div>
|
||||
<div class="form-group"><label class="form-label">Project</label>
|
||||
<select name="project_id" class="form-select"><option value="">-- None --</option>{% for p in projects %}<option value="{{ p.id }}" {{ 'selected' if (item and item.project_id and item.project_id|string == p.id|string) or (not item and prefill_project_id == p.id|string) }}>{{ p.name }}</option>{% endfor %}</select></div>
|
||||
<div class="form-group full-width"><label class="form-label">Content</label><textarea name="body" class="form-textarea" rows="15" style="font-family:var(--font-mono);font-size:0.88rem">{{ item.body if item and item.body else '' }}</textarea></div>
|
||||
<div class="form-group full-width"><label class="form-label">Tags</label><input type="text" name="tags" class="form-input" value="{{ item.tags|join(', ') if item and item.tags else '' }}"></div>
|
||||
<input type="hidden" name="content_format" value="rich">
|
||||
<div class="form-actions"><button type="submit" class="btn btn-primary">{{ 'Save' if item else 'Create' }}</button><a href="{{ '/notes/' ~ item.id if item else '/notes' }}" class="btn btn-secondary">Cancel</a></div>
|
||||
</div>
|
||||
</form></div>
|
||||
{% endblock %}
|
||||
25
templates/notes.html
Normal file
25
templates/notes.html
Normal file
@@ -0,0 +1,25 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Notes<span class="page-count">{{ items|length }}</span></h1>
|
||||
<a href="/notes/create" class="btn btn-primary">+ New Note</a>
|
||||
</div>
|
||||
{% if items %}
|
||||
<div class="card">
|
||||
{% for item in items %}
|
||||
<div class="list-row">
|
||||
{% 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>
|
||||
{% if item.project_name %}<span class="row-tag">{{ item.project_name }}</span>{% endif %}
|
||||
<span class="row-meta">{{ item.updated_at.strftime('%Y-%m-%d') if item.updated_at else '' }}</span>
|
||||
<div class="row-actions">
|
||||
<a href="/notes/{{ item.id }}/edit" class="btn btn-ghost btn-xs">Edit</a>
|
||||
<form action="/notes/{{ item.id }}/delete" method="post" data-confirm="Delete?" style="display:inline"><button class="btn btn-ghost btn-xs" style="color:var(--red)">Del</button></form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state"><div class="empty-state-icon">📄</div><div class="empty-state-text">No notes yet</div><a href="/notes/create" class="btn btn-primary">Create Note</a></div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
95
templates/project_detail.html
Normal file
95
templates/project_detail.html
Normal file
@@ -0,0 +1,95 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="breadcrumb">
|
||||
{% if domain %}<span style="color: {{ domain.color or '#4F6EF7' }}">{{ domain.name }}</span><span class="sep">/</span>{% endif %}
|
||||
{% if area %}<span>{{ area.name }}</span><span class="sep">/</span>{% endif %}
|
||||
<span>{{ item.name }}</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-header">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="detail-title">{{ item.name }}</h1>
|
||||
<div class="flex gap-2">
|
||||
<a href="/projects/{{ item.id }}/edit" class="btn btn-secondary btn-sm">Edit</a>
|
||||
<form action="/projects/{{ item.id }}/delete" method="post" data-confirm="Delete this project?" style="display:inline">
|
||||
<button class="btn btn-danger btn-sm">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-meta mt-2">
|
||||
<span class="status-badge status-{{ item.status }}">{{ item.status|replace('_', ' ') }}</span>
|
||||
<span class="detail-meta-item"><span class="priority-dot priority-{{ item.priority }}"></span> P{{ item.priority }}</span>
|
||||
{% if item.target_date %}<span class="detail-meta-item">Target: {{ item.target_date }}</span>{% endif %}
|
||||
</div>
|
||||
<div class="mt-2" style="max-width: 300px;">
|
||||
<div class="progress-bar"><div class="progress-fill" style="width: {{ progress }}%"></div></div>
|
||||
<div class="progress-text">{{ done_count }}/{{ task_count }} tasks complete ({{ progress }}%)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if item.description %}
|
||||
<div class="card mb-4"><div class="detail-body">{{ item.description }}</div></div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="tab-strip">
|
||||
<a href="/projects/{{ item.id }}?tab=tasks" class="tab-item {{ 'active' if tab == 'tasks' }}">Tasks ({{ tasks|length }})</a>
|
||||
<a href="/projects/{{ item.id }}?tab=notes" class="tab-item {{ 'active' if tab == 'notes' }}">Notes ({{ notes|length }})</a>
|
||||
<a href="/projects/{{ item.id }}?tab=links" class="tab-item {{ 'active' if tab == 'links' }}">Links ({{ links|length }})</a>
|
||||
</div>
|
||||
|
||||
{% if tab == 'tasks' %}
|
||||
<form class="quick-add" action="/tasks/quick-add" method="post">
|
||||
<input type="hidden" name="domain_id" value="{{ item.domain_id }}">
|
||||
<input type="hidden" name="project_id" value="{{ item.id }}">
|
||||
<input type="text" name="title" placeholder="Quick add task to this project..." required>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Add</button>
|
||||
</form>
|
||||
<a href="/tasks/create?domain_id={{ item.domain_id }}&project_id={{ item.id }}" class="btn btn-ghost btn-sm mb-3">+ Full Task Form</a>
|
||||
|
||||
{% for t in tasks %}
|
||||
<div class="list-row {{ 'completed' if t.status == 'done' }}">
|
||||
<div class="row-check">
|
||||
<form action="/tasks/{{ t.id }}/toggle" method="post" style="display:inline">
|
||||
<input type="checkbox" id="pt-{{ t.id }}" {{ 'checked' if t.status == 'done' }} onchange="this.form.submit()">
|
||||
<label for="pt-{{ t.id }}"></label>
|
||||
</form>
|
||||
</div>
|
||||
<span class="priority-dot priority-{{ t.priority }}"></span>
|
||||
<span class="row-title"><a href="/tasks/{{ t.id }}">{{ t.title }}</a></span>
|
||||
{% if t.due_date %}<span class="row-meta">{{ t.due_date }}</span>{% endif %}
|
||||
<span class="status-badge status-{{ t.status }}">{{ t.status|replace('_', ' ') }}</span>
|
||||
<div class="row-actions">
|
||||
<a href="/tasks/{{ t.id }}/edit" class="btn btn-ghost btn-xs">Edit</a>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state"><div class="empty-state-text">No tasks yet</div></div>
|
||||
{% endfor %}
|
||||
|
||||
{% elif tab == 'notes' %}
|
||||
<a href="/notes/create?domain_id={{ item.domain_id }}&project_id={{ item.id }}" class="btn btn-ghost btn-sm mb-3">+ New Note</a>
|
||||
{% for n in notes %}
|
||||
<div class="list-row">
|
||||
<span class="row-title"><a href="/notes/{{ n.id }}">{{ n.title }}</a></span>
|
||||
<span class="row-meta">{{ n.updated_at.strftime('%Y-%m-%d') if n.updated_at else '' }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state"><div class="empty-state-text">No notes yet</div></div>
|
||||
{% endfor %}
|
||||
|
||||
{% elif tab == 'links' %}
|
||||
<a href="/links/create?domain_id={{ item.domain_id }}&project_id={{ item.id }}" class="btn btn-ghost btn-sm mb-3">+ New Link</a>
|
||||
{% for l in links %}
|
||||
<div class="list-row">
|
||||
<span class="row-title"><a href="{{ l.url }}" target="_blank">{{ l.label }}</a></span>
|
||||
<span class="row-meta">{{ l.url[:50] }}{% if l.url|length > 50 %}...{% endif %}</span>
|
||||
<div class="row-actions">
|
||||
<a href="/links/{{ l.id }}/edit" class="btn btn-ghost btn-xs">Edit</a>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state"><div class="empty-state-text">No links yet</div></div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
69
templates/project_form.html
Normal file
69
templates/project_form.html
Normal file
@@ -0,0 +1,69 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="breadcrumb"><a href="/projects">Projects</a><span class="sep">/</span><span>{{ 'Edit' if item else 'New Project' }}</span></div>
|
||||
<div class="page-header"><h1 class="page-title">{{ 'Edit Project' if item else 'New Project' }}</h1></div>
|
||||
<div class="card">
|
||||
<form method="post" action="{{ '/projects/' ~ item.id ~ '/edit' if item else '/projects/create' }}">
|
||||
<div class="form-grid">
|
||||
<div class="form-group full-width">
|
||||
<label class="form-label">Name *</label>
|
||||
<input type="text" name="name" class="form-input" required value="{{ item.name if item else '' }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Domain *</label>
|
||||
<select name="domain_id" class="form-select" required>
|
||||
{% for d in domains %}
|
||||
<option value="{{ d.id }}" {{ 'selected' if (item and item.domain_id|string == d.id|string) or (not item and prefill_domain_id == d.id|string) }}>{{ d.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Area</label>
|
||||
<select name="area_id" class="form-select">
|
||||
<option value="">-- None --</option>
|
||||
{% for a in areas %}
|
||||
<option value="{{ a.id }}" {{ 'selected' if (item and item.area_id and item.area_id|string == a.id|string) or (not item and prefill_area_id == a.id|string) }}>{{ a.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Status</label>
|
||||
<select name="status" class="form-select">
|
||||
{% for s in ['active', 'on_hold', 'completed', 'archived'] %}
|
||||
<option value="{{ s }}" {{ 'selected' if item and item.status == s }}>{{ s|replace('_', ' ')|title }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Priority</label>
|
||||
<select name="priority" class="form-select">
|
||||
<option value="1" {{ 'selected' if item and item.priority == 1 }}>Critical</option>
|
||||
<option value="2" {{ 'selected' if item and item.priority == 2 }}>High</option>
|
||||
<option value="3" {{ 'selected' if (item and item.priority == 3) or not item }}>Normal</option>
|
||||
<option value="4" {{ 'selected' if item and item.priority == 4 }}>Low</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Start Date</label>
|
||||
<input type="date" name="start_date" class="form-input" value="{{ item.start_date if item and item.start_date else '' }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Target Date</label>
|
||||
<input type="date" name="target_date" class="form-input" value="{{ item.target_date if item and item.target_date else '' }}">
|
||||
</div>
|
||||
<div class="form-group full-width">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea name="description" class="form-textarea">{{ item.description if item and item.description else '' }}</textarea>
|
||||
</div>
|
||||
<div class="form-group full-width">
|
||||
<label class="form-label">Tags (comma-separated)</label>
|
||||
<input type="text" name="tags" class="form-input" value="{{ item.tags|join(', ') if item and item.tags else '' }}">
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">{{ 'Save Changes' if item else 'Create Project' }}</button>
|
||||
<a href="{{ '/projects/' ~ item.id if item else '/projects' }}" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
48
templates/projects.html
Normal file
48
templates/projects.html
Normal file
@@ -0,0 +1,48 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Projects<span class="page-count">{{ items|length }}</span></h1>
|
||||
<a href="/projects/create" class="btn btn-primary">+ New Project</a>
|
||||
</div>
|
||||
|
||||
<form class="filters-bar" method="get" action="/projects">
|
||||
<select name="domain_id" class="filter-select" onchange="this.form.submit()">
|
||||
<option value="">All Domains</option>
|
||||
{% for d in domains %}
|
||||
<option value="{{ d.id }}" {{ 'selected' if current_domain_id == d.id|string }}>{{ d.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<select name="status" class="filter-select" onchange="this.form.submit()">
|
||||
<option value="">All Statuses</option>
|
||||
{% for s in ['active', 'on_hold', 'completed', 'archived'] %}
|
||||
<option value="{{ s }}" {{ 'selected' if current_status == s }}>{{ s|replace('_', ' ')|title }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</form>
|
||||
|
||||
{% if items %}
|
||||
<div class="card">
|
||||
{% for item in items %}
|
||||
<div class="list-row">
|
||||
<span class="row-domain-tag" style="background: {{ item.domain_color or '#4F6EF7' }}22; color: {{ item.domain_color or '#4F6EF7' }}">{{ item.domain_name }}</span>
|
||||
<span class="row-title"><a href="/projects/{{ item.id }}">{{ item.name }}</a></span>
|
||||
{% if item.area_name %}<span class="row-meta">{{ item.area_name }}</span>{% endif %}
|
||||
<span class="status-badge status-{{ item.status }}">{{ item.status|replace('_', ' ') }}</span>
|
||||
<div style="width: 80px;">
|
||||
<div class="progress-bar"><div class="progress-fill" style="width: {{ item.progress }}%"></div></div>
|
||||
<div class="progress-text">{{ item.done_count }}/{{ item.task_count }}</div>
|
||||
</div>
|
||||
<div class="row-actions">
|
||||
<a href="/projects/{{ item.id }}/edit" class="btn btn-ghost btn-xs">Edit</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">📂</div>
|
||||
<div class="empty-state-text">No projects found</div>
|
||||
<a href="/projects/create" class="btn btn-primary">Create First Project</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
79
templates/task_detail.html
Normal file
79
templates/task_detail.html
Normal file
@@ -0,0 +1,79 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="breadcrumb">
|
||||
{% if domain %}<a href="/tasks?domain_id={{ item.domain_id }}">{{ domain.name }}</a><span class="sep">/</span>{% endif %}
|
||||
{% if project %}<a href="/projects/{{ project.id }}">{{ project.name }}</a><span class="sep">/</span>{% endif %}
|
||||
<span>{{ item.title }}</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-header">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="detail-title">{{ item.title }}</h1>
|
||||
<div class="flex gap-2">
|
||||
<a href="/tasks/{{ item.id }}/edit" class="btn btn-secondary btn-sm">Edit</a>
|
||||
<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">
|
||||
{{ 'Reopen' if item.status == 'done' else 'Complete' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-meta mt-2">
|
||||
<span class="status-badge status-{{ item.status }}">{{ item.status|replace('_', ' ') }}</span>
|
||||
<span class="detail-meta-item"><span class="priority-dot priority-{{ item.priority }}"></span> P{{ item.priority }}</span>
|
||||
{% if domain %}<span class="row-domain-tag" style="background: {{ domain.color or '#4F6EF7' }}22; color: {{ domain.color or '#4F6EF7' }}">{{ domain.name }}</span>{% endif %}
|
||||
{% if project %}<span class="row-tag">{{ project.name }}</span>{% endif %}
|
||||
{% if item.due_date %}<span class="detail-meta-item">Due: {{ item.due_date }}</span>{% endif %}
|
||||
{% if item.context %}<span class="detail-meta-item">@{{ item.context }}</span>{% endif %}
|
||||
{% if item.estimated_minutes %}<span class="detail-meta-item">~{{ item.estimated_minutes }}min</span>{% endif %}
|
||||
{% if item.energy_required %}<span class="detail-meta-item">Energy: {{ item.energy_required }}</span>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if item.description %}
|
||||
<div class="card mb-4">
|
||||
<div class="detail-body">{{ item.description }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if item.tags %}
|
||||
<div class="flex gap-2 mb-4">
|
||||
{% for tag in item.tags %}<span class="row-tag">{{ tag }}</span>{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if parent %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-title text-sm">Parent Task</div>
|
||||
<a href="/tasks/{{ parent.id }}">{{ parent.title }}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Subtasks -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">Subtasks<span class="page-count">{{ subtasks|length }}</span></h2>
|
||||
<a href="/tasks/create?parent_id={{ item.id }}&domain_id={{ item.domain_id }}&project_id={{ item.project_id or '' }}" class="btn btn-ghost btn-sm">+ Add Subtask</a>
|
||||
</div>
|
||||
{% for sub in subtasks %}
|
||||
<div class="list-row {{ 'completed' if sub.status == 'done' }}">
|
||||
<div class="row-check">
|
||||
<form action="/tasks/{{ sub.id }}/toggle" method="post" style="display:inline">
|
||||
<input type="checkbox" id="sub-{{ sub.id }}" {{ 'checked' if sub.status == 'done' }} onchange="this.form.submit()">
|
||||
<label for="sub-{{ sub.id }}"></label>
|
||||
</form>
|
||||
</div>
|
||||
<span class="priority-dot priority-{{ sub.priority }}"></span>
|
||||
<span class="row-title"><a href="/tasks/{{ sub.id }}">{{ sub.title }}</a></span>
|
||||
<span class="status-badge status-{{ sub.status }}">{{ sub.status|replace('_', ' ') }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-sm text-muted" style="padding: 12px;">No subtasks</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-muted mt-4">
|
||||
Created {{ item.created_at.strftime('%Y-%m-%d %H:%M') if item.created_at else '' }}
|
||||
{% if item.completed_at %} | Completed {{ item.completed_at.strftime('%Y-%m-%d %H:%M') }}{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
119
templates/task_form.html
Normal file
119
templates/task_form.html
Normal file
@@ -0,0 +1,119 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="breadcrumb">
|
||||
<a href="/tasks">Tasks</a>
|
||||
<span class="sep">/</span>
|
||||
<span>{{ 'Edit' if item else 'New Task' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">{{ 'Edit Task' if item else 'New Task' }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<form method="post" action="{{ '/tasks/' ~ item.id ~ '/edit' if item else '/tasks/create' }}">
|
||||
<div class="form-grid">
|
||||
<div class="form-group full-width">
|
||||
<label class="form-label">Title *</label>
|
||||
<input type="text" name="title" class="form-input" required
|
||||
value="{{ item.title if item else '' }}">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Domain *</label>
|
||||
<select name="domain_id" class="form-select" required>
|
||||
{% for d in domains %}
|
||||
<option value="{{ d.id }}"
|
||||
{{ 'selected' if (item and item.domain_id|string == d.id|string) or (not item and prefill_domain_id == d.id|string) }}>
|
||||
{{ d.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Project</label>
|
||||
<select name="project_id" class="form-select">
|
||||
<option value="">-- No Project --</option>
|
||||
{% for p in projects %}
|
||||
<option value="{{ p.id }}"
|
||||
{{ 'selected' if (item and item.project_id and item.project_id|string == p.id|string) or (not item and prefill_project_id == p.id|string) }}>
|
||||
{{ p.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Priority</label>
|
||||
<select name="priority" class="form-select">
|
||||
<option value="1" {{ 'selected' if item and item.priority == 1 }}>1 - Critical</option>
|
||||
<option value="2" {{ 'selected' if item and item.priority == 2 }}>2 - High</option>
|
||||
<option value="3" {{ 'selected' if (item and item.priority == 3) or not item }}>3 - Normal</option>
|
||||
<option value="4" {{ 'selected' if item and item.priority == 4 }}>4 - Low</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Status</label>
|
||||
<select name="status" class="form-select">
|
||||
{% for s in ['open', 'in_progress', 'blocked', 'done', 'cancelled'] %}
|
||||
<option value="{{ s }}" {{ 'selected' if item and item.status == s }}>{{ s|replace('_', ' ')|title }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Due Date</label>
|
||||
<input type="date" name="due_date" class="form-input"
|
||||
value="{{ item.due_date if item and item.due_date else '' }}">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Context</label>
|
||||
<select name="context" class="form-select">
|
||||
<option value="">-- None --</option>
|
||||
{% for ct in context_types %}
|
||||
<option value="{{ ct.value }}"
|
||||
{{ 'selected' if item and item.context == ct.value }}>{{ ct.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Estimated Minutes</label>
|
||||
<input type="number" name="estimated_minutes" class="form-input" min="0"
|
||||
value="{{ item.estimated_minutes if item and item.estimated_minutes else '' }}">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Energy Required</label>
|
||||
<select name="energy_required" class="form-select">
|
||||
<option value="">-- None --</option>
|
||||
<option value="high" {{ 'selected' if item and item.energy_required == 'high' }}>High</option>
|
||||
<option value="medium" {{ 'selected' if item and item.energy_required == 'medium' }}>Medium</option>
|
||||
<option value="low" {{ 'selected' if item and item.energy_required == 'low' }}>Low</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group full-width">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea name="description" class="form-textarea">{{ item.description if item and item.description else '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group full-width">
|
||||
<label class="form-label">Tags (comma-separated)</label>
|
||||
<input type="text" name="tags" class="form-input"
|
||||
value="{{ item.tags|join(', ') if item and item.tags else '' }}">
|
||||
</div>
|
||||
|
||||
{% if prefill_parent_id %}
|
||||
<input type="hidden" name="parent_id" value="{{ prefill_parent_id }}">
|
||||
{% endif %}
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">{{ 'Save Changes' if item else 'Create Task' }}</button>
|
||||
<a href="{{ '/tasks/' ~ item.id if item else '/tasks' }}" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
91
templates/tasks.html
Normal file
91
templates/tasks.html
Normal file
@@ -0,0 +1,91 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">All Tasks<span class="page-count">{{ items|length }}</span></h1>
|
||||
<a href="/tasks/create" class="btn btn-primary">+ New Task</a>
|
||||
</div>
|
||||
|
||||
<!-- Quick Add -->
|
||||
<form class="quick-add" action="/tasks/quick-add" method="post">
|
||||
<input type="text" name="title" placeholder="Quick add task..." required>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Add</button>
|
||||
</form>
|
||||
|
||||
<!-- Filters -->
|
||||
<form class="filters-bar" method="get" action="/tasks">
|
||||
<select name="status" class="filter-select" data-auto-submit onchange="this.form.submit()">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="open" {{ 'selected' if current_status == 'open' }}>Open</option>
|
||||
<option value="in_progress" {{ 'selected' if current_status == 'in_progress' }}>In Progress</option>
|
||||
<option value="blocked" {{ 'selected' if current_status == 'blocked' }}>Blocked</option>
|
||||
<option value="done" {{ 'selected' if current_status == 'done' }}>Done</option>
|
||||
</select>
|
||||
<select name="priority" class="filter-select" onchange="this.form.submit()">
|
||||
<option value="">All Priorities</option>
|
||||
<option value="1" {{ 'selected' if current_priority == '1' }}>Critical</option>
|
||||
<option value="2" {{ 'selected' if current_priority == '2' }}>High</option>
|
||||
<option value="3" {{ 'selected' if current_priority == '3' }}>Normal</option>
|
||||
<option value="4" {{ 'selected' if current_priority == '4' }}>Low</option>
|
||||
</select>
|
||||
<select name="domain_id" class="filter-select" onchange="this.form.submit()">
|
||||
<option value="">All Domains</option>
|
||||
{% for d in domains %}
|
||||
<option value="{{ d.id }}" {{ 'selected' if current_domain_id == d.id|string }}>{{ d.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<select name="project_id" class="filter-select" onchange="this.form.submit()">
|
||||
<option value="">All Projects</option>
|
||||
{% for p in projects %}
|
||||
<option value="{{ p.id }}" {{ 'selected' if current_project_id == p.id|string }}>{{ p.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<select name="sort" class="filter-select" onchange="this.form.submit()">
|
||||
<option value="sort_order" {{ 'selected' if current_sort == 'sort_order' }}>Manual Order</option>
|
||||
<option value="priority" {{ 'selected' if current_sort == 'priority' }}>Priority</option>
|
||||
<option value="due_date" {{ 'selected' if current_sort == 'due_date' }}>Due Date</option>
|
||||
<option value="created_at" {{ 'selected' if current_sort == 'created_at' }}>Newest</option>
|
||||
<option value="title" {{ 'selected' if current_sort == 'title' }}>Title</option>
|
||||
</select>
|
||||
</form>
|
||||
|
||||
<!-- Task List -->
|
||||
{% if items %}
|
||||
<div class="card">
|
||||
{% for item in items %}
|
||||
<div class="list-row {{ 'completed' if item.status in ['done', 'cancelled'] }}">
|
||||
<div class="row-check">
|
||||
<form action="/tasks/{{ item.id }}/toggle" method="post" style="display:inline">
|
||||
<input type="checkbox" id="check-{{ item.id }}" {{ 'checked' if item.status == 'done' }}
|
||||
onchange="this.form.submit()">
|
||||
<label for="check-{{ item.id }}"></label>
|
||||
</form>
|
||||
</div>
|
||||
<span class="priority-dot priority-{{ item.priority }}"></span>
|
||||
<span class="row-title"><a href="/tasks/{{ item.id }}">{{ item.title }}</a></span>
|
||||
{% if item.project_name %}
|
||||
<span class="row-tag">{{ item.project_name }}</span>
|
||||
{% endif %}
|
||||
{% if item.domain_name %}
|
||||
<span class="row-domain-tag" style="background: {{ item.domain_color or '#4F6EF7' }}22; color: {{ item.domain_color or '#4F6EF7' }}">{{ item.domain_name }}</span>
|
||||
{% endif %}
|
||||
{% if item.due_date %}
|
||||
<span class="row-meta {{ 'overdue' if item.due_date|string < now_date|default('9999') }}">{{ item.due_date }}</span>
|
||||
{% endif %}
|
||||
<span class="status-badge status-{{ item.status }}">{{ item.status|replace('_', ' ') }}</span>
|
||||
<div class="row-actions">
|
||||
<a href="/tasks/{{ item.id }}/edit" class="btn btn-ghost btn-xs">Edit</a>
|
||||
<form action="/tasks/{{ item.id }}/delete" method="post" data-confirm="Delete this task?" style="display:inline">
|
||||
<button type="submit" class="btn btn-ghost btn-xs" style="color: var(--red)">Del</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">☑</div>
|
||||
<div class="empty-state-text">No tasks found</div>
|
||||
<a href="/tasks/create" class="btn btn-primary">Create First Task</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user