raztodo-web 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- raztodo_web/__init__.py +0 -0
- raztodo_web/__main__.py +36 -0
- raztodo_web/app/__init__.py +0 -0
- raztodo_web/app/dependencies.py +40 -0
- raztodo_web/app/factory.py +39 -0
- raztodo_web/features/explain/__init__.py +0 -0
- raztodo_web/features/explain/routes.py +42 -0
- raztodo_web/features/tasks/helpers.py +23 -0
- raztodo_web/features/tasks/routes.py +202 -0
- raztodo_web/features/tasks/schemas.py +48 -0
- raztodo_web/web/static/css/base.css +22 -0
- raztodo_web/web/static/css/components/forms.css +216 -0
- raztodo_web/web/static/css/components/modal.css +157 -0
- raztodo_web/web/static/css/components/sidebar.css +88 -0
- raztodo_web/web/static/css/components/toast.css +32 -0
- raztodo_web/web/static/css/features/tasks.css +251 -0
- raztodo_web/web/static/css/layout.css +79 -0
- raztodo_web/web/static/css/responsive.css +44 -0
- raztodo_web/web/static/css/style.css +28 -0
- raztodo_web/web/static/css/theme.css +90 -0
- raztodo_web/web/static/css/tokens.css +59 -0
- raztodo_web/web/static/img/export.svg +1 -0
- raztodo_web/web/static/img/favicon.ico +0 -0
- raztodo_web/web/static/img/import.svg +1 -0
- raztodo_web/web/static/img/search.svg +1 -0
- raztodo_web/web/static/img/trash.svg +1 -0
- raztodo_web/web/static/js/app.js +11 -0
- raztodo_web/web/static/js/explain/index.js +19 -0
- raztodo_web/web/static/js/explain/modal.js +111 -0
- raztodo_web/web/static/js/shared/api.js +13 -0
- raztodo_web/web/static/js/shared/priority.js +94 -0
- raztodo_web/web/static/js/shared/state.js +12 -0
- raztodo_web/web/static/js/shared/theme.js +30 -0
- raztodo_web/web/static/js/shared/toast.js +23 -0
- raztodo_web/web/static/js/tasks/actions.js +142 -0
- raztodo_web/web/static/js/tasks/edit.js +55 -0
- raztodo_web/web/static/js/tasks/index.js +70 -0
- raztodo_web/web/static/js/tasks/render.js +143 -0
- raztodo_web/web/templates/index.html +49 -0
- raztodo_web/web/templates/partials/_explain_modal.html +20 -0
- raztodo_web/web/templates/partials/_main_header.html +18 -0
- raztodo_web/web/templates/partials/_sidebar_new_task.html +70 -0
- raztodo_web/web/templates/partials/_sidebar_search.html +27 -0
- raztodo_web/web/templates/partials/_sidebar_tools.html +41 -0
- raztodo_web/web/templates/partials/_toast.html +1 -0
- raztodo_web-0.1.0.dist-info/METADATA +126 -0
- raztodo_web-0.1.0.dist-info/RECORD +49 -0
- raztodo_web-0.1.0.dist-info/WHEEL +4 -0
- raztodo_web-0.1.0.dist-info/entry_points.txt +3 -0
raztodo_web/__init__.py
ADDED
|
File without changes
|
raztodo_web/__main__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def web_host() -> str:
|
|
7
|
+
return os.getenv("RAZTODO_WEB_HOST", "127.0.0.1")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def web_port() -> int:
|
|
11
|
+
return int(os.getenv("RAZTODO_WEB_PORT", "8000"))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main() -> None:
|
|
15
|
+
import importlib.util
|
|
16
|
+
|
|
17
|
+
if (
|
|
18
|
+
importlib.util.find_spec("fastapi") is None
|
|
19
|
+
or importlib.util.find_spec("uvicorn") is None
|
|
20
|
+
):
|
|
21
|
+
raise SystemExit(
|
|
22
|
+
"Web dependencies are not installed. Install with: pip install 'raztodo[web]'"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
import uvicorn # type: ignore[import]
|
|
26
|
+
|
|
27
|
+
uvicorn.run(
|
|
28
|
+
"raztodo_web.app.factory:app",
|
|
29
|
+
host=web_host(),
|
|
30
|
+
port=web_port(),
|
|
31
|
+
reload=False,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
if __name__ == "__main__":
|
|
36
|
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Annotated, Any
|
|
4
|
+
|
|
5
|
+
from fastapi import Depends
|
|
6
|
+
from raztodo.application.factory import DefaultUseCaseFactory
|
|
7
|
+
from raztodo.infrastructure.container import build_container
|
|
8
|
+
from raztodo.infrastructure.sqlite.task_repository import SQLiteTaskRepository
|
|
9
|
+
|
|
10
|
+
_container = build_container()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_storage() -> SQLiteTaskRepository:
|
|
14
|
+
return _container.repo_singleton()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_factory() -> DefaultUseCaseFactory:
|
|
18
|
+
return DefaultUseCaseFactory()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
StorageDep = Annotated[SQLiteTaskRepository, Depends(get_storage)]
|
|
22
|
+
FactoryDep = Annotated[DefaultUseCaseFactory, Depends(get_factory)]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_use_case(factory_method: str):
|
|
26
|
+
def _dependency(storage: StorageDep, factory: FactoryDep) -> Any:
|
|
27
|
+
return getattr(factory, factory_method)(storage)
|
|
28
|
+
|
|
29
|
+
return _dependency
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
get_list_uc = get_use_case("create_list_tasks")
|
|
33
|
+
get_create_uc = get_use_case("create_create_task")
|
|
34
|
+
get_update_uc = get_use_case("create_update_task")
|
|
35
|
+
get_delete_uc = get_use_case("create_delete_task")
|
|
36
|
+
get_clear_uc = get_use_case("create_clear_tasks")
|
|
37
|
+
get_mark_done_uc = get_use_case("create_mark_done")
|
|
38
|
+
get_export_uc = get_use_case("create_export_tasks")
|
|
39
|
+
get_import_uc = get_use_case("create_import_tasks")
|
|
40
|
+
get_explain_uc = get_use_case("create_explain_task")
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from fastapi import FastAPI, Request
|
|
6
|
+
from fastapi.staticfiles import StaticFiles
|
|
7
|
+
from fastapi.templating import Jinja2Templates
|
|
8
|
+
from raztodo.infrastructure.version import get_version
|
|
9
|
+
|
|
10
|
+
from raztodo_web.features.explain.routes import router as explain_router
|
|
11
|
+
from raztodo_web.features.tasks.routes import router as tasks_router
|
|
12
|
+
|
|
13
|
+
_WEB_DIR = Path(__file__).resolve().parent.parent / "web"
|
|
14
|
+
_STATIC_DIR = _WEB_DIR / "static"
|
|
15
|
+
_TEMPLATES_DIR = _WEB_DIR / "templates"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
app = FastAPI(
|
|
19
|
+
title="RazTodo",
|
|
20
|
+
description="Local web interface for RazTodo",
|
|
21
|
+
version=get_version(),
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
app.mount(
|
|
25
|
+
"/static",
|
|
26
|
+
StaticFiles(directory=_STATIC_DIR),
|
|
27
|
+
name="static",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
templates = Jinja2Templates(directory=_TEMPLATES_DIR)
|
|
31
|
+
|
|
32
|
+
app.include_router(tasks_router)
|
|
33
|
+
app.include_router(explain_router)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@app.get("/", include_in_schema=False)
|
|
37
|
+
async def index(request: Request):
|
|
38
|
+
"""Serve the single-page UI, rendered from templates/index.html."""
|
|
39
|
+
return templates.TemplateResponse(request, "index.html")
|
|
File without changes
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
4
|
+
from fastapi.responses import StreamingResponse
|
|
5
|
+
from raztodo.domain.exceptions import RazTodoException
|
|
6
|
+
|
|
7
|
+
from raztodo_web.app.dependencies import get_explain_uc
|
|
8
|
+
|
|
9
|
+
router = APIRouter(prefix="/api/tasks", tags=["tasks"])
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@router.get("/{task_id}/explain")
|
|
13
|
+
def explain_task(
|
|
14
|
+
task_id: int,
|
|
15
|
+
mode: str = "short",
|
|
16
|
+
uc: Any = Depends(get_explain_uc), # noqa: B008
|
|
17
|
+
) -> StreamingResponse:
|
|
18
|
+
"""
|
|
19
|
+
Stream the LLM explanation as Server-Sent Events (SSE).
|
|
20
|
+
Each event: data: <token>\n\n
|
|
21
|
+
Final event: data: [DONE]\n\n
|
|
22
|
+
"""
|
|
23
|
+
if mode not in ("short", "deep", "plan"):
|
|
24
|
+
raise HTTPException(
|
|
25
|
+
status_code=422, detail="mode must be: short, deep, or plan"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
def _sse_generator():
|
|
29
|
+
try:
|
|
30
|
+
for token in uc.stream(task_id, mode=mode):
|
|
31
|
+
safe = token.replace("\n", "\\n")
|
|
32
|
+
yield f"data: {safe}\n\n"
|
|
33
|
+
except RazTodoException as _exc:
|
|
34
|
+
yield "event: error\ndata: An internal error occurred.\n\n"
|
|
35
|
+
finally:
|
|
36
|
+
yield "data: [DONE]\n\n"
|
|
37
|
+
|
|
38
|
+
return StreamingResponse(
|
|
39
|
+
_sse_generator(),
|
|
40
|
+
media_type="text/event-stream",
|
|
41
|
+
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
|
42
|
+
)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from raztodo_web.features.tasks.schemas import TaskResponse
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def remove_file(path: str) -> None:
|
|
8
|
+
if os.path.exists(path):
|
|
9
|
+
os.unlink(path)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def task_to_response(task: Any) -> TaskResponse:
|
|
13
|
+
return TaskResponse(
|
|
14
|
+
id=task.id,
|
|
15
|
+
title=task.title,
|
|
16
|
+
description=getattr(task, "description", "") or "",
|
|
17
|
+
done=getattr(task, "done", False),
|
|
18
|
+
created_at=getattr(task, "created_at", "") or "",
|
|
19
|
+
priority=getattr(task, "priority", "") or "",
|
|
20
|
+
due_date=getattr(task, "due_date", None),
|
|
21
|
+
tags=list(getattr(task, "tags", None) or []),
|
|
22
|
+
project=getattr(task, "project", None),
|
|
23
|
+
)
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import tempfile
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
|
9
|
+
from fastapi.responses import FileResponse
|
|
10
|
+
from raztodo.domain.exceptions import RazTodoException
|
|
11
|
+
|
|
12
|
+
from raztodo_web.app.dependencies import (
|
|
13
|
+
get_clear_uc,
|
|
14
|
+
get_create_uc,
|
|
15
|
+
get_delete_uc,
|
|
16
|
+
get_export_uc,
|
|
17
|
+
get_import_uc,
|
|
18
|
+
get_list_uc,
|
|
19
|
+
get_mark_done_uc,
|
|
20
|
+
get_update_uc,
|
|
21
|
+
)
|
|
22
|
+
from raztodo_web.features.tasks.helpers import task_to_response
|
|
23
|
+
from raztodo_web.features.tasks.schemas import (
|
|
24
|
+
ClearResponse,
|
|
25
|
+
ImportResponse,
|
|
26
|
+
TaskCreate,
|
|
27
|
+
TaskResponse,
|
|
28
|
+
TaskUpdate,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
router = APIRouter(prefix="/api/tasks", tags=["tasks"])
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _domain_error(e: Exception) -> HTTPException:
|
|
35
|
+
return HTTPException(status_code=400, detail=str(e))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _remove_file(path: str) -> None:
|
|
39
|
+
if os.path.exists(path):
|
|
40
|
+
os.unlink(path)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@router.get("", response_model=list[TaskResponse])
|
|
44
|
+
def list_tasks(
|
|
45
|
+
q: str | None = None,
|
|
46
|
+
uc: Any = Depends(get_list_uc), # noqa: B008
|
|
47
|
+
) -> list[TaskResponse]:
|
|
48
|
+
try:
|
|
49
|
+
tasks = uc.execute()
|
|
50
|
+
if q:
|
|
51
|
+
q_lower = q.lower()
|
|
52
|
+
tasks = [
|
|
53
|
+
t
|
|
54
|
+
for t in tasks
|
|
55
|
+
if q_lower in t.title.lower()
|
|
56
|
+
or q_lower in (getattr(t, "description", "") or "").lower()
|
|
57
|
+
]
|
|
58
|
+
return [task_to_response(t) for t in tasks]
|
|
59
|
+
except RazTodoException as e:
|
|
60
|
+
raise _domain_error(e) from e
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@router.post("", response_model=TaskResponse, status_code=201)
|
|
64
|
+
def create_task(
|
|
65
|
+
body: TaskCreate,
|
|
66
|
+
list_uc: Any = Depends(get_list_uc), # noqa: B008
|
|
67
|
+
create_uc: Any = Depends(get_create_uc), # noqa: B008
|
|
68
|
+
) -> TaskResponse:
|
|
69
|
+
try:
|
|
70
|
+
task_id: int = create_uc.execute(
|
|
71
|
+
title=body.title,
|
|
72
|
+
description=body.description,
|
|
73
|
+
priority=body.priority or "",
|
|
74
|
+
due_date=body.due_date,
|
|
75
|
+
tags=body.tags or [],
|
|
76
|
+
project=body.project,
|
|
77
|
+
)
|
|
78
|
+
tasks = list_uc.execute()
|
|
79
|
+
task = next((t for t in tasks if t.id == task_id), None)
|
|
80
|
+
if task is None:
|
|
81
|
+
raise HTTPException(
|
|
82
|
+
status_code=500, detail="Task created but could not be retrieved"
|
|
83
|
+
)
|
|
84
|
+
return task_to_response(task)
|
|
85
|
+
except RazTodoException as e:
|
|
86
|
+
raise _domain_error(e) from e
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@router.get("/export")
|
|
90
|
+
def export_tasks(
|
|
91
|
+
background_tasks: BackgroundTasks,
|
|
92
|
+
uc: Any = Depends(get_export_uc), # noqa: B008
|
|
93
|
+
) -> FileResponse:
|
|
94
|
+
try:
|
|
95
|
+
fd, path = tempfile.mkstemp(suffix=".json")
|
|
96
|
+
os.close(fd)
|
|
97
|
+
|
|
98
|
+
uc.execute(path)
|
|
99
|
+
background_tasks.add_task(_remove_file, path)
|
|
100
|
+
|
|
101
|
+
return FileResponse(
|
|
102
|
+
path=path,
|
|
103
|
+
media_type="application/json",
|
|
104
|
+
filename="raztodo_export.json",
|
|
105
|
+
)
|
|
106
|
+
except RazTodoException as e:
|
|
107
|
+
raise _domain_error(e) from e
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@router.post("/import", response_model=ImportResponse)
|
|
111
|
+
def import_tasks(
|
|
112
|
+
tasks: list[TaskCreate],
|
|
113
|
+
uc: Any = Depends(get_import_uc), # noqa: B008
|
|
114
|
+
) -> ImportResponse:
|
|
115
|
+
tmp = tempfile.NamedTemporaryFile( # noqa: SIM115
|
|
116
|
+
suffix=".json", delete=False, mode="w", encoding="utf-8"
|
|
117
|
+
)
|
|
118
|
+
try:
|
|
119
|
+
json.dump([t.model_dump() for t in tasks], tmp, ensure_ascii=False)
|
|
120
|
+
tmp.close()
|
|
121
|
+
result = uc.execute(tmp.name, upsert=True)
|
|
122
|
+
if isinstance(result, dict):
|
|
123
|
+
return ImportResponse(
|
|
124
|
+
inserted=result.get("inserted", 0),
|
|
125
|
+
updated=result.get("updated", 0),
|
|
126
|
+
)
|
|
127
|
+
return ImportResponse(inserted=int(result), updated=0)
|
|
128
|
+
except RazTodoException as e:
|
|
129
|
+
raise _domain_error(e) from e
|
|
130
|
+
except Exception as e:
|
|
131
|
+
raise HTTPException(status_code=422, detail=f"Import failed: {e}") from e
|
|
132
|
+
finally:
|
|
133
|
+
tmp.close()
|
|
134
|
+
if os.path.exists(tmp.name):
|
|
135
|
+
os.unlink(tmp.name)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@router.post("/clear", response_model=ClearResponse)
|
|
139
|
+
def clear_tasks(uc: Any = Depends(get_clear_uc)) -> ClearResponse: # noqa: B008
|
|
140
|
+
try:
|
|
141
|
+
deleted: int = uc.execute(confirmed=True)
|
|
142
|
+
return ClearResponse(deleted=deleted)
|
|
143
|
+
except RazTodoException as e:
|
|
144
|
+
raise _domain_error(e) from e
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@router.put("/{task_id}", response_model=TaskResponse)
|
|
148
|
+
def update_task(
|
|
149
|
+
task_id: int,
|
|
150
|
+
body: TaskUpdate,
|
|
151
|
+
list_uc: Any = Depends(get_list_uc), # noqa: B008
|
|
152
|
+
update_uc: Any = Depends(get_update_uc), # noqa: B008
|
|
153
|
+
) -> TaskResponse:
|
|
154
|
+
try:
|
|
155
|
+
update_uc.execute(
|
|
156
|
+
task_id,
|
|
157
|
+
title=body.title,
|
|
158
|
+
description=body.description,
|
|
159
|
+
priority=body.priority if body.priority is not None else "",
|
|
160
|
+
due_date=body.due_date if body.due_date is not None else "",
|
|
161
|
+
tags=body.tags if body.tags is not None else [],
|
|
162
|
+
project=body.project if body.project is not None else "",
|
|
163
|
+
)
|
|
164
|
+
tasks = list_uc.execute()
|
|
165
|
+
task = next((t for t in tasks if t.id == task_id), None)
|
|
166
|
+
if task is None:
|
|
167
|
+
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
|
|
168
|
+
return task_to_response(task)
|
|
169
|
+
except RazTodoException as e:
|
|
170
|
+
raise _domain_error(e) from e
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@router.delete("/{task_id}", status_code=204)
|
|
174
|
+
def delete_task(
|
|
175
|
+
task_id: int,
|
|
176
|
+
uc: Any = Depends(get_delete_uc), # noqa: B008
|
|
177
|
+
) -> None:
|
|
178
|
+
try:
|
|
179
|
+
uc.execute(task_id)
|
|
180
|
+
except RazTodoException as e:
|
|
181
|
+
raise HTTPException(status_code=404, detail=str(e)) from e
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@router.patch("/{task_id}/done", response_model=TaskResponse)
|
|
185
|
+
def toggle_done(
|
|
186
|
+
task_id: int,
|
|
187
|
+
list_uc: Any = Depends(get_list_uc), # noqa: B008
|
|
188
|
+
mark_uc: Any = Depends(get_mark_done_uc), # noqa: B008
|
|
189
|
+
) -> TaskResponse:
|
|
190
|
+
try:
|
|
191
|
+
tasks = list_uc.execute()
|
|
192
|
+
task = next((t for t in tasks if t.id == task_id), None)
|
|
193
|
+
if task is None:
|
|
194
|
+
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
|
|
195
|
+
mark_uc.execute(task_id, done=not task.done)
|
|
196
|
+
tasks = list_uc.execute()
|
|
197
|
+
task = next((t for t in tasks if t.id == task_id), None)
|
|
198
|
+
if task is None:
|
|
199
|
+
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
|
|
200
|
+
return task_to_response(task)
|
|
201
|
+
except RazTodoException as e:
|
|
202
|
+
raise _domain_error(e) from e
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TaskCreate(BaseModel):
|
|
7
|
+
title: str = Field(..., min_length=1, max_length=60)
|
|
8
|
+
description: str = Field(default="")
|
|
9
|
+
priority: str | None = Field(default=None, pattern="^[LMH]$")
|
|
10
|
+
due_date: str | None = Field(default=None)
|
|
11
|
+
tags: list[str] = Field(default_factory=list)
|
|
12
|
+
project: str | None = Field(default=None)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TaskUpdate(BaseModel):
|
|
16
|
+
title: str | None = Field(default=None, min_length=1, max_length=60)
|
|
17
|
+
description: str | None = Field(default=None)
|
|
18
|
+
priority: str | None = Field(default=None, pattern="^[LMH]$")
|
|
19
|
+
due_date: str | None = Field(default=None)
|
|
20
|
+
tags: list[str] | None = Field(default=None)
|
|
21
|
+
project: str | None = Field(default=None)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TaskResponse(BaseModel):
|
|
25
|
+
id: int
|
|
26
|
+
title: str
|
|
27
|
+
description: str = ""
|
|
28
|
+
done: bool = False
|
|
29
|
+
created_at: str = ""
|
|
30
|
+
priority: str = ""
|
|
31
|
+
due_date: str | None = None
|
|
32
|
+
tags: list[str] = Field(default_factory=list)
|
|
33
|
+
project: str | None = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ImportPayload(BaseModel):
|
|
37
|
+
"""Raw JSON list of task dicts — validated at use-case level."""
|
|
38
|
+
|
|
39
|
+
model_config = {"arbitrary_types_allowed": True}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ClearResponse(BaseModel):
|
|
43
|
+
deleted: int
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ImportResponse(BaseModel):
|
|
47
|
+
inserted: int
|
|
48
|
+
updated: int
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/* ── Reset ─────────────────────────────────────────── */
|
|
2
|
+
*,
|
|
3
|
+
*::before,
|
|
4
|
+
*::after {
|
|
5
|
+
box-sizing: border-box;
|
|
6
|
+
margin: 0;
|
|
7
|
+
padding: 0;
|
|
8
|
+
}
|
|
9
|
+
html,
|
|
10
|
+
body {
|
|
11
|
+
height: 100%;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/* ── Base ──────────────────────────────────────────── */
|
|
15
|
+
body {
|
|
16
|
+
font-family: var(--font);
|
|
17
|
+
background: var(--bg-app);
|
|
18
|
+
color: var(--text-primary);
|
|
19
|
+
font-size: 15px;
|
|
20
|
+
line-height: 1.6;
|
|
21
|
+
-webkit-font-smoothing: antialiased;
|
|
22
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/* ── Form elements ─────────────────────────────────── */
|
|
2
|
+
.form-stack {
|
|
3
|
+
display: flex;
|
|
4
|
+
flex-direction: column;
|
|
5
|
+
gap: 8px;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.form-row {
|
|
9
|
+
display: grid;
|
|
10
|
+
grid-template-columns: 1fr 1fr;
|
|
11
|
+
gap: 8px;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.field {
|
|
15
|
+
display: flex;
|
|
16
|
+
flex-direction: column;
|
|
17
|
+
gap: 4px;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.field label {
|
|
21
|
+
font-size: 11px;
|
|
22
|
+
color: var(--text-label);
|
|
23
|
+
font-weight: 500;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
input,
|
|
27
|
+
select {
|
|
28
|
+
width: 100%;
|
|
29
|
+
padding: 9px 11px;
|
|
30
|
+
border-radius: var(--r-sm);
|
|
31
|
+
border: 1px solid var(--border-input);
|
|
32
|
+
background: var(--bg-input);
|
|
33
|
+
color: var(--text-primary);
|
|
34
|
+
font-family: var(--font);
|
|
35
|
+
font-size: 14px;
|
|
36
|
+
transition:
|
|
37
|
+
border-color var(--t),
|
|
38
|
+
box-shadow var(--t);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
input:hover,
|
|
42
|
+
select:hover {
|
|
43
|
+
border-color: var(--border-focus);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
input::placeholder {
|
|
48
|
+
color: var(--text-muted);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
input:focus,
|
|
53
|
+
select:focus {
|
|
54
|
+
outline: none;
|
|
55
|
+
border-color: var(--border-focus);
|
|
56
|
+
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.04);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
input.input-primary {
|
|
60
|
+
font-size: 15px;
|
|
61
|
+
padding: 11px 12px;
|
|
62
|
+
border-color: var(--border-input);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
select option {
|
|
66
|
+
background: var(--bg-panel);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
input.input-primary:hover {
|
|
70
|
+
border-color: var(--border-focus);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/* ── Buttons ───────────────────────────────────────── */
|
|
74
|
+
.btn-primary {
|
|
75
|
+
width: 100%;
|
|
76
|
+
padding: 11px 16px;
|
|
77
|
+
border-radius: var(--r-sm);
|
|
78
|
+
border: none;
|
|
79
|
+
background: var(--accent);
|
|
80
|
+
color: #fff;
|
|
81
|
+
font-family: var(--font);
|
|
82
|
+
font-size: 14px;
|
|
83
|
+
font-weight: 500;
|
|
84
|
+
cursor: pointer;
|
|
85
|
+
transition:
|
|
86
|
+
background var(--t),
|
|
87
|
+
transform var(--t);
|
|
88
|
+
letter-spacing: 0.01em;
|
|
89
|
+
margin-top: 4px;
|
|
90
|
+
}
|
|
91
|
+
.btn-primary:hover {
|
|
92
|
+
background: var(--accent-hover);
|
|
93
|
+
}
|
|
94
|
+
.btn-primary:active {
|
|
95
|
+
transform: scale(0.98);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.btn-ghost {
|
|
99
|
+
display: flex;
|
|
100
|
+
align-items: center;
|
|
101
|
+
gap: 9px;
|
|
102
|
+
width: 100%;
|
|
103
|
+
padding: 9px 11px;
|
|
104
|
+
border-radius: var(--r-sm);
|
|
105
|
+
border: none;
|
|
106
|
+
background: transparent;
|
|
107
|
+
color: var(--text-secondary);
|
|
108
|
+
font-family: var(--font);
|
|
109
|
+
font-size: 14px;
|
|
110
|
+
cursor: pointer;
|
|
111
|
+
transition:
|
|
112
|
+
background var(--t),
|
|
113
|
+
color var(--t);
|
|
114
|
+
text-align: left;
|
|
115
|
+
}
|
|
116
|
+
.btn-ghost svg {
|
|
117
|
+
width: 17px;
|
|
118
|
+
height: 17px;
|
|
119
|
+
flex-shrink: 0;
|
|
120
|
+
}
|
|
121
|
+
.btn-ghost:hover {
|
|
122
|
+
background: var(--bg-hover);
|
|
123
|
+
color: var(--text-primary);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.btn-danger-ghost:hover {
|
|
127
|
+
background: var(--bg-hover);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.tool-buttons {
|
|
131
|
+
display: flex;
|
|
132
|
+
flex-direction: column;
|
|
133
|
+
gap: 2px;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/* ── Search ────────────────────────────────────────── */
|
|
137
|
+
.search-wrap {
|
|
138
|
+
position: relative;
|
|
139
|
+
}
|
|
140
|
+
.search-icon {
|
|
141
|
+
position: absolute;
|
|
142
|
+
left: 9px;
|
|
143
|
+
top: 50%;
|
|
144
|
+
transform: translateY(-50%);
|
|
145
|
+
width: 15px;
|
|
146
|
+
height: 15px;
|
|
147
|
+
color: var(--text-muted);
|
|
148
|
+
pointer-events: none;
|
|
149
|
+
}
|
|
150
|
+
.search-wrap input {
|
|
151
|
+
padding-left: 32px;
|
|
152
|
+
background: var(--bg-input);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/* ── Priority picker ───────────────────────────────── */
|
|
156
|
+
.priority-picker {
|
|
157
|
+
display: flex;
|
|
158
|
+
align-items: stretch;
|
|
159
|
+
width: 100%;
|
|
160
|
+
height: 39px;
|
|
161
|
+
border-radius: var(--r-sm);
|
|
162
|
+
border: 1px solid var(--border-input);
|
|
163
|
+
background: var(--bg-input);
|
|
164
|
+
overflow: hidden;
|
|
165
|
+
transition: border-color var(--t), box-shadow var(--t);
|
|
166
|
+
}
|
|
167
|
+
.priority-picker:focus-within {
|
|
168
|
+
border-color: var(--border-focus);
|
|
169
|
+
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.04);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.priority-arrow {
|
|
173
|
+
flex-shrink: 0;
|
|
174
|
+
width: 36px;
|
|
175
|
+
border: none;
|
|
176
|
+
background: transparent;
|
|
177
|
+
color: var(--text-secondary);
|
|
178
|
+
font-size: 22px;
|
|
179
|
+
line-height: 1;
|
|
180
|
+
cursor: pointer;
|
|
181
|
+
display: flex;
|
|
182
|
+
align-items: center;
|
|
183
|
+
justify-content: center;
|
|
184
|
+
transition: background var(--t), color var(--t);
|
|
185
|
+
padding: 0;
|
|
186
|
+
}
|
|
187
|
+
.priority-arrow:hover:not(:disabled) {
|
|
188
|
+
background: var(--bg-hover);
|
|
189
|
+
color: var(--text-primary);
|
|
190
|
+
}
|
|
191
|
+
.priority-arrow:disabled {
|
|
192
|
+
opacity: 0.25;
|
|
193
|
+
cursor: default;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
.priority-label {
|
|
197
|
+
flex: 1;
|
|
198
|
+
text-align: center;
|
|
199
|
+
font-size: 14px;
|
|
200
|
+
font-weight: 500;
|
|
201
|
+
color: var(--text-primary);
|
|
202
|
+
user-select: none;
|
|
203
|
+
pointer-events: none;
|
|
204
|
+
display: flex;
|
|
205
|
+
align-items: center;
|
|
206
|
+
justify-content: center;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/* Light theme */
|
|
210
|
+
[data-theme="light"] .priority-picker {
|
|
211
|
+
background: var(--bg-input);
|
|
212
|
+
border-color: var(--border-input);
|
|
213
|
+
}
|
|
214
|
+
[data-theme="light"] .priority-arrow:hover:not(:disabled) {
|
|
215
|
+
background: var(--bg-hover);
|
|
216
|
+
}
|