feat: M4 docker deployment - bridge + frontend containers
- packages/bridge/Dockerfile: multi-stage build (node:22-alpine) - frontend/Dockerfile: multi-stage build + nginx:alpine - frontend/nginx.conf: proxy /bridge to bridge service - docker-compose.yml: bridge (3456) + frontend (8080) - .dockerignore: exclude backend/node_modules/dist - Fix vite proxy target to port 3456 - .env with bridge vars (AUTH_MODE=dev)
This commit is contained in:
parent
dbf290e49a
commit
ade88808fb
38 changed files with 3508 additions and 2930 deletions
32
.dockerignore
Normal file
32
.dockerignore
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
.venv
|
||||||
|
__pycache__
|
||||||
|
.pytest_cache
|
||||||
|
.ruff_cache
|
||||||
|
.mypy_cache
|
||||||
|
*.py[cod]
|
||||||
|
|
||||||
|
# Node modules (re-installed in Docker)
|
||||||
|
**/node_modules
|
||||||
|
|
||||||
|
# Build outputs (re-built in Docker)
|
||||||
|
frontend/dist
|
||||||
|
packages/*/dist
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Dev/test artifacts
|
||||||
|
coverage
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
|
||||||
|
# Docker-in-Docker
|
||||||
|
docker-compose.yml
|
||||||
|
**/Dockerfile
|
||||||
|
|
||||||
|
# Backend (not needed for bridge/frontend containers)
|
||||||
|
backend/
|
||||||
26
.env.example
26
.env.example
|
|
@ -1,9 +1,21 @@
|
||||||
|
# ─── Bridge Configuration ──────────────────────────────
|
||||||
|
BRIDGE_HOST=127.0.0.1
|
||||||
|
BRIDGE_PORT=8787
|
||||||
|
BRIDGE_TOKEN=dev-secret-token
|
||||||
|
AUTH_MODE=dev
|
||||||
|
BRIDGE_ALLOWED_BOARDS=*
|
||||||
|
COMMAND_TIMEOUT_SECONDS=30
|
||||||
|
LOG_TAIL_MAX_LINES=500
|
||||||
|
|
||||||
|
# ─── Hermes CLI ────────────────────────────────────────
|
||||||
HERMES_BIN=hermes
|
HERMES_BIN=hermes
|
||||||
HERMES_HOME=/home/your-user/.hermes
|
HERMES_HOME=/home/kisskin/.hermes
|
||||||
HERMES_KANBAN_BOARD=default
|
|
||||||
KANBAN_UI_AUTH_MODE=dev
|
# ─── Telegram (optional, for production) ───────────────
|
||||||
TELEGRAM_BOT_TOKEN=
|
TELEGRAM_BOT_TOKEN=
|
||||||
TELEGRAM_ALLOWED_USER_IDS=
|
|
||||||
KANBAN_UI_ALLOWED_ORIGINS=http://localhost:5173
|
# ─── Frontend (Vite) ──────────────────────────────────
|
||||||
KANBAN_UI_COMMAND_TIMEOUT_SECONDS=30
|
# In dev mode the vite proxy handles /bridge → localhost:8787
|
||||||
VITE_API_BASE_URL=http://localhost:8000
|
# Set VITE_BRIDGE_URL only if frontend talks to bridge directly
|
||||||
|
# VITE_BRIDGE_URL=http://localhost:8787/bridge
|
||||||
|
# VITE_AUTH_TOKEN=dev-secret-token
|
||||||
|
|
|
||||||
11
Dockerfile
11
Dockerfile
|
|
@ -1,8 +1,17 @@
|
||||||
|
FROM node:22-alpine AS frontend-build
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
COPY frontend/package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
FROM python:3.11-slim AS backend
|
FROM python:3.11-slim AS backend
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY backend/pyproject.toml /app/backend/pyproject.toml
|
COPY backend/pyproject.toml /app/backend/pyproject.toml
|
||||||
COPY backend/app /app/backend/app
|
COPY backend/app /app/backend/app
|
||||||
|
COPY --from=frontend-build /app/frontend/dist /app/frontend/dist
|
||||||
RUN pip install --no-cache-dir -e /app/backend
|
RUN pip install --no-cache-dir -e /app/backend
|
||||||
ENV KANBAN_UI_AUTH_MODE=telegram
|
ENV KANBAN_UI_AUTH_MODE=telegram \
|
||||||
|
KANBAN_UI_FRONTEND_DIST_DIR=/app/frontend/dist
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
CMD ["uvicorn", "app.main:app", "--app-dir", "backend", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["uvicorn", "app.main:app", "--app-dir", "backend", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,7 @@ Important settings:
|
||||||
- `TELEGRAM_BOT_TOKEN`: bot token used only by backend for `initData` validation.
|
- `TELEGRAM_BOT_TOKEN`: bot token used only by backend for `initData` validation.
|
||||||
- `TELEGRAM_ALLOWED_USER_IDS`: optional comma-separated Telegram user id allowlist.
|
- `TELEGRAM_ALLOWED_USER_IDS`: optional comma-separated Telegram user id allowlist.
|
||||||
- `KANBAN_UI_ALLOWED_ORIGINS`: CORS allowlist.
|
- `KANBAN_UI_ALLOWED_ORIGINS`: CORS allowlist.
|
||||||
|
- `KANBAN_UI_FRONTEND_DIST_DIR`: optional built frontend directory for single-container serving.
|
||||||
- `VITE_API_BASE_URL`: frontend API base URL for production builds.
|
- `VITE_API_BASE_URL`: frontend API base URL for production builds.
|
||||||
|
|
||||||
## Telegram Mini App setup
|
## Telegram Mini App setup
|
||||||
|
|
@ -126,6 +127,9 @@ npm ci
|
||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The included Dockerfile builds the frontend and serves it from the FastAPI container on port `8000`.
|
||||||
|
For same-origin Docker deployment, leave `VITE_API_BASE_URL` empty at build time and expose only the backend container.
|
||||||
|
|
||||||
## Security model
|
## Security model
|
||||||
|
|
||||||
- Backend accepts only fixed API actions mapped to fixed Hermes Kanban CLI subcommands.
|
- Backend accepts only fixed API actions mapped to fixed Hermes Kanban CLI subcommands.
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
|
import json
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from typing import Literal
|
from typing import Annotated, Literal
|
||||||
|
|
||||||
from pydantic import Field, field_validator
|
from pydantic import Field, field_validator
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
|
|
@ -13,17 +14,27 @@ class Settings(BaseSettings):
|
||||||
default_board: str = Field(default="default", alias="HERMES_KANBAN_BOARD")
|
default_board: str = Field(default="default", alias="HERMES_KANBAN_BOARD")
|
||||||
auth_mode: Literal["telegram", "none", "dev"] = Field(default="dev", alias="KANBAN_UI_AUTH_MODE")
|
auth_mode: Literal["telegram", "none", "dev"] = Field(default="dev", alias="KANBAN_UI_AUTH_MODE")
|
||||||
telegram_bot_token: str | None = Field(default=None, alias="TELEGRAM_BOT_TOKEN")
|
telegram_bot_token: str | None = Field(default=None, alias="TELEGRAM_BOT_TOKEN")
|
||||||
telegram_allowed_user_ids: list[int] = Field(default_factory=list, alias="TELEGRAM_ALLOWED_USER_IDS")
|
telegram_allowed_user_ids: Annotated[list[int], NoDecode] = Field(default_factory=list, alias="TELEGRAM_ALLOWED_USER_IDS")
|
||||||
allowed_origins: list[str] = Field(default_factory=lambda: ["http://localhost:5173"], alias="KANBAN_UI_ALLOWED_ORIGINS")
|
allowed_origins: Annotated[list[str], NoDecode] = Field(default_factory=lambda: ["http://localhost:5173"], alias="KANBAN_UI_ALLOWED_ORIGINS")
|
||||||
command_timeout_seconds: int = Field(default=30, alias="KANBAN_UI_COMMAND_TIMEOUT_SECONDS")
|
command_timeout_seconds: int = Field(default=30, alias="KANBAN_UI_COMMAND_TIMEOUT_SECONDS")
|
||||||
|
frontend_dist_dir: str | None = Field(default=None, alias="KANBAN_UI_FRONTEND_DIST_DIR")
|
||||||
|
|
||||||
@field_validator("telegram_allowed_user_ids", mode="before")
|
@field_validator("telegram_allowed_user_ids", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def parse_user_ids(cls, value):
|
def parse_user_ids(cls, value):
|
||||||
if value is None or value == "":
|
if value is None or value == "":
|
||||||
return []
|
return []
|
||||||
|
if isinstance(value, str):
|
||||||
|
value = value.strip()
|
||||||
|
if value.startswith("["):
|
||||||
|
try:
|
||||||
|
value = json.loads(value)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
return [int(part.strip()) for part in value.split(",") if part.strip()]
|
return [int(part.strip()) for part in value.split(",") if part.strip()]
|
||||||
|
if isinstance(value, (list, tuple)):
|
||||||
|
return [int(v) for v in value]
|
||||||
return value
|
return value
|
||||||
|
|
||||||
@field_validator("allowed_origins", mode="before")
|
@field_validator("allowed_origins", mode="before")
|
||||||
|
|
@ -31,8 +42,17 @@ class Settings(BaseSettings):
|
||||||
def parse_origins(cls, value):
|
def parse_origins(cls, value):
|
||||||
if value is None or value == "":
|
if value is None or value == "":
|
||||||
return []
|
return []
|
||||||
|
if isinstance(value, str):
|
||||||
|
value = value.strip()
|
||||||
|
if value.startswith("["):
|
||||||
|
try:
|
||||||
|
value = json.loads(value)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
return [part.strip() for part in value.split(",") if part.strip()]
|
return [part.strip() for part in value.split(",") if part.strip()]
|
||||||
|
if isinstance(value, (list, tuple)):
|
||||||
|
return [str(v) for v in value]
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,14 @@ def create_app(settings: Settings | None = None, adapter: KanbanAdapter | None =
|
||||||
def dispatch(board: str | None = Query(default=None), user=Depends(require_user)):
|
def dispatch(board: str | None = Query(default=None), user=Depends(require_user)):
|
||||||
return safe_call(adapter.dispatch, selected_board(board))
|
return safe_call(adapter.dispatch, selected_board(board))
|
||||||
|
|
||||||
|
if settings.frontend_dist_dir:
|
||||||
|
from pathlib import Path
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
frontend_dir = Path(settings.frontend_dist_dir)
|
||||||
|
if frontend_dir.is_dir():
|
||||||
|
app.mount("/", StaticFiles(directory=frontend_dir, html=True), name="frontend")
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,35 @@ def test_allowed_user_filter_rejects_unauthorized(fake_adapter):
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_env_list_settings_accept_json_strings():
|
||||||
|
settings = Settings(
|
||||||
|
telegram_allowed_user_ids='[123, "456"]',
|
||||||
|
allowed_origins='["https://miniapp.example", "http://localhost:5173"]',
|
||||||
|
)
|
||||||
|
assert settings.telegram_allowed_user_ids == [123, 456]
|
||||||
|
assert settings.allowed_origins == ["https://miniapp.example", "http://localhost:5173"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_env_list_settings_accept_comma_separated_strings():
|
||||||
|
settings = Settings(
|
||||||
|
telegram_allowed_user_ids="123,456",
|
||||||
|
allowed_origins="https://miniapp.example,http://localhost:5173",
|
||||||
|
)
|
||||||
|
assert settings.telegram_allowed_user_ids == [123, 456]
|
||||||
|
assert settings.allowed_origins == ["https://miniapp.example", "http://localhost:5173"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_static_frontend_can_be_served_from_backend(tmp_path, fake_adapter):
|
||||||
|
(tmp_path / "index.html").write_text("<h1>Kanban Mini App</h1>", encoding="utf-8")
|
||||||
|
settings = Settings(auth_mode="none", frontend_dist_dir=str(tmp_path))
|
||||||
|
app = create_app(settings, fake_adapter)
|
||||||
|
|
||||||
|
response = TestClient(app).get("/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Kanban Mini App" in response.text
|
||||||
|
|
||||||
|
|
||||||
def test_task_list_returns_normalized_tasks_grouped_by_status(client):
|
def test_task_list_returns_normalized_tasks_grouped_by_status(client):
|
||||||
data = client.get("/api/tasks").json()
|
data = client.get("/api/tasks").json()
|
||||||
assert data["groups"]["ready"][0]["id"] == "t_1"
|
assert data["groups"]["ready"][0]["id"] == "t_1"
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,48 @@
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# docker-compose.yml — Hermes Kanban MiniApp deployment
|
||||||
|
# Services: bridge (Express API) + frontend (Nginx SPA)
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
services:
|
services:
|
||||||
api:
|
# ── Bridge API ───────────────────────────────────────────
|
||||||
build: .
|
bridge:
|
||||||
env_file: .env
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: packages/bridge/Dockerfile
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "3456:3456"
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
# Override bind to 0.0.0.0 so container is reachable
|
||||||
|
BRIDGE_HOST: "0.0.0.0"
|
||||||
|
BRIDGE_PORT: "3456"
|
||||||
volumes:
|
volumes:
|
||||||
- ${HERMES_HOME:-/home/your-user/.hermes}:${HERMES_HOME:-/home/your-user/.hermes}:ro
|
# Mount hermes profile directory (read-only)
|
||||||
|
- ${HERMES_HOME:-/home/kisskin/.hermes}:/root/.hermes:ro
|
||||||
|
networks:
|
||||||
|
- kanban
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3456/bridge/v1/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
# ── Frontend (Nginx SPA) ─────────────────────────────────
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: frontend/Dockerfile
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
depends_on:
|
||||||
|
bridge:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- kanban
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
frontend:
|
networks:
|
||||||
image: node:22-alpine
|
kanban:
|
||||||
working_dir: /app/frontend
|
driver: bridge
|
||||||
command: sh -c "npm ci && npm run dev -- --host 0.0.0.0"
|
|
||||||
environment:
|
|
||||||
VITE_API_BASE_URL: http://localhost:8000
|
|
||||||
volumes:
|
|
||||||
- .:/app
|
|
||||||
ports:
|
|
||||||
- "5173:5173"
|
|
||||||
depends_on:
|
|
||||||
- api
|
|
||||||
|
|
|
||||||
665
docs/architecture-adr.md
Normal file
665
docs/architecture-adr.md
Normal file
|
|
@ -0,0 +1,665 @@
|
||||||
|
# ADR-001: Community architecture for Hermes Kanban Telegram Mini App
|
||||||
|
|
||||||
|
Status: Proposed for implementation
|
||||||
|
Date: 2026-07-10
|
||||||
|
Decision owner: project maintainer
|
||||||
|
Scope: open-source community release, before rewriting the current local FastAPI prototype
|
||||||
|
|
||||||
|
## 1. Context
|
||||||
|
|
||||||
|
The current repository contains an uncommitted prototype with a Vite/React frontend and a Python/FastAPI backend that shells out to `hermes kanban`. The target changed: the public release should be best-practice, secure, easy to install, documented in English and Russian, pleasant as a Telegram Mini App and normal browser/mobile app, and work well on Vercel.
|
||||||
|
|
||||||
|
Hard constraint: a static Vercel frontend cannot safely execute a user's local `hermes` CLI or read local `~/.hermes/kanban.db`. Any architecture that claims otherwise is untrustworthy. A remote browser UI must talk to a trusted server-side component that is already authorized to reach the user's Hermes instance.
|
||||||
|
|
||||||
|
## 2. Verified facts used for this ADR
|
||||||
|
|
||||||
|
### Hermes Agent
|
||||||
|
|
||||||
|
From the official Hermes docs and local checked source at `/home/kisskin/.hermes/hermes-agent`:
|
||||||
|
|
||||||
|
- Hermes API Server exposes Hermes as an OpenAI-compatible HTTP endpoint for external frontends (`POST /v1/chat/completions`, `POST /v1/responses`, `POST /v1/runs`, sessions, jobs, skills/toolsets discovery, health). Source: `/home/kisskin/.hermes/hermes-agent/website/docs/user-guide/features/api-server.md`.
|
||||||
|
- API Server is enabled with env vars including `API_SERVER_ENABLED=true`, `API_SERVER_KEY`, `API_SERVER_HOST`, `API_SERVER_PORT`, and optional `API_SERVER_CORS_ORIGINS`. Default bind host is `127.0.0.1`; bearer auth is required. Source: same file, Configuration and Authentication sections.
|
||||||
|
- Hermes API Server is powerful: it gives access to the agent's toolset including terminal commands, so the key must never be exposed to a browser. Source: same file, Security warning.
|
||||||
|
- Current API Server capabilities do not advertise first-class Kanban CRUD endpoints. The capabilities list includes chat/responses/runs, sessions, jobs, skills, and toolsets; searching `gateway/platforms/api_server.py` found no `/api/kanban` route.
|
||||||
|
- Hermes Kanban is a durable SQLite-backed board. Tasks, comments, links, workspaces, logs, dispatcher behavior, boards, and board isolation are documented in `/home/kisskin/.hermes/hermes-agent/website/docs/user-guide/features/kanban.md`.
|
||||||
|
- Hermes Kanban has two official surfaces today: agents use `kanban_*` tools, while humans/scripts use `hermes kanban ...` CLI, slash command, or the Hermes dashboard. Source: Kanban docs section "Two surfaces".
|
||||||
|
|
||||||
|
### Telegram Mini Apps
|
||||||
|
|
||||||
|
From official Telegram Mini Apps docs and Telegram core docs:
|
||||||
|
|
||||||
|
- Mini App init data is intended to be used as an authentication/authorization factor.
|
||||||
|
- Server validation requires excluding `hash`, sorting key-value pairs, joining them with `\n`, deriving a secret with HMAC-SHA256 over the bot token using `WebAppData`, then HMAC-SHA256 over the data-check string and comparing hex digest to `hash`.
|
||||||
|
- The bot token must remain server-side. A frontend may forward raw init data to its backend, but must not validate with the bot token in the browser.
|
||||||
|
- The official React ecosystem template for Telegram Mini Apps uses React, TypeScript, Vite, `@tma.js/sdk-react` / Telegram Apps SDK packages, React Router HashRouter, and Telegram UI components.
|
||||||
|
|
||||||
|
### Vercel / browser deployment
|
||||||
|
|
||||||
|
- Vercel is a good fit for Vite static frontend hosting and for lightweight Node.js serverless API routes.
|
||||||
|
- Vercel serverless functions can call HTTPS APIs, but they cannot be trusted to execute a user's private local `hermes` CLI or access a local SQLite board unless the user separately exposes a backend/bridge over HTTPS.
|
||||||
|
- Browser-side direct calls to Hermes API Server are unsafe for a community default because they would require exposing `API_SERVER_KEY` to JavaScript. If direct browser mode is documented, it must be explicitly marked local/dev only.
|
||||||
|
|
||||||
|
### Comparable open-source Telegram Mini Apps
|
||||||
|
|
||||||
|
GitHub search verified active public projects/templates:
|
||||||
|
|
||||||
|
- `Telegram-Mini-Apps/reactjs-template`: React, TypeScript, Vite, Telegram Mini Apps SDK, Telegram UI; suitable baseline for project structure.
|
||||||
|
- `telegram-mini-apps-dev/TelegramUI`: React component library inspired by Telegram interface; suitable UI dependency.
|
||||||
|
- Other public Telegram Mini App examples commonly combine React/TypeScript with Next.js or Vite and place secrets in a backend/server component, not in static browser code.
|
||||||
|
|
||||||
|
## 3. Decision
|
||||||
|
|
||||||
|
Use this stack decisively:
|
||||||
|
|
||||||
|
1. Frontend: React + TypeScript + Vite, hosted as static assets on Vercel or any static host.
|
||||||
|
2. Telegram SDK/UI: `@telegram-apps/sdk-react` (or current successor package from Telegram Mini Apps docs) + `@telegram-apps/telegram-ui` for native-feeling Telegram UX.
|
||||||
|
3. API layer for Vercel/community default: TypeScript serverless BFF (Backend-for-Frontend) API routes deployed on Vercel.
|
||||||
|
4. Local integration: optional self-hosted Hermes Kanban Bridge, implemented in TypeScript/Node.js, running on the same trusted machine as Hermes.
|
||||||
|
5. Data source: the bridge uses stable `hermes kanban ... --json` CLI commands first, with fixed argv and `shell: false`. It may later switch to official Hermes Kanban HTTP endpoints if Hermes adds them.
|
||||||
|
6. Authentication: Vercel BFF validates Telegram `initData` server-side and issues a short-lived app session. The BFF then calls the self-hosted bridge using a separate bridge token. The bridge never trusts the browser directly.
|
||||||
|
7. Documentation: docs must include English and Russian install paths for Vercel + bridge, Docker Compose, and local dev.
|
||||||
|
|
||||||
|
Short version: `Vercel static React Mini App + Vercel TS BFF + optional self-hosted TS bridge near Hermes`.
|
||||||
|
|
||||||
|
## 4. Why not keep the prototype stack as-is?
|
||||||
|
|
||||||
|
The Python/FastAPI prototype proved the shape, but it is not the best public target because:
|
||||||
|
|
||||||
|
- The user prefers TypeScript/JS and Vercel.
|
||||||
|
- A Vercel-centered release is simpler if frontend and BFF share TypeScript types and validation schemas.
|
||||||
|
- A Python backend on Vercel is possible but less ergonomic for this use case than a small Node BFF plus a separate self-hosted bridge.
|
||||||
|
- The current prototype mixes concerns: Telegram auth, browser CORS, Hermes CLI subprocess, and static serving in one backend. For community release, the trust boundary must be explicit.
|
||||||
|
|
||||||
|
Python/FastAPI is not rejected forever; it becomes a migration source and optional legacy adapter, not the recommended architecture.
|
||||||
|
|
||||||
|
## 5. Target architecture
|
||||||
|
|
||||||
|
```text
|
||||||
|
Telegram client / mobile browser / desktop browser
|
||||||
|
|
|
||||||
|
| HTTPS, static assets, no secrets
|
||||||
|
v
|
||||||
|
Vercel-hosted React + TypeScript + Vite app
|
||||||
|
|
|
||||||
|
| Authorization: tma <Telegram initData>
|
||||||
|
| or app session cookie/JWT after validation
|
||||||
|
v
|
||||||
|
Vercel TypeScript BFF API routes
|
||||||
|
| - validates Telegram initData with TELEGRAM_BOT_TOKEN
|
||||||
|
| - enforces allowed Telegram user IDs / roles
|
||||||
|
| - rate limits and normalizes errors
|
||||||
|
| - holds no Hermes CLI access
|
||||||
|
|
|
||||||
|
| HTTPS + Bridge token, server-to-server only
|
||||||
|
v
|
||||||
|
Optional self-hosted Hermes Kanban Bridge (Node.js)
|
||||||
|
| - runs on trusted host near Hermes
|
||||||
|
| - validates bridge token/mTLS or reverse-proxy auth
|
||||||
|
| - maps narrow REST endpoints to fixed Hermes Kanban operations
|
||||||
|
| - uses child_process.spawn/execFile with shell=false
|
||||||
|
v
|
||||||
|
Hermes install / profile
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Hermes Kanban board SQLite + dispatcher + logs/workspaces
|
||||||
|
```
|
||||||
|
|
||||||
|
### Local-only shortcut
|
||||||
|
|
||||||
|
For local development only:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Vite dev server -> local bridge on 127.0.0.1 -> hermes kanban CLI
|
||||||
|
```
|
||||||
|
|
||||||
|
This mode may use `AUTH_MODE=dev` and a fake Telegram user fixture, but it must be impossible to enable in production without an explicit env var.
|
||||||
|
|
||||||
|
## 6. Trust boundaries
|
||||||
|
|
||||||
|
### Public browser / Mini App
|
||||||
|
|
||||||
|
Trusted for display only. Not trusted for identity, permissions, board slug, task IDs, or action payloads. It must not receive:
|
||||||
|
|
||||||
|
- Telegram bot token.
|
||||||
|
- Hermes API Server key.
|
||||||
|
- Bridge token.
|
||||||
|
- Local filesystem paths beyond what the bridge explicitly exposes as display-safe metadata.
|
||||||
|
- Raw command output containing secrets.
|
||||||
|
|
||||||
|
### Vercel BFF
|
||||||
|
|
||||||
|
Trusted to authenticate Telegram users and authorize UI actions. It has:
|
||||||
|
|
||||||
|
- `TELEGRAM_BOT_TOKEN`.
|
||||||
|
- `SESSION_SECRET` or JWT signing secret.
|
||||||
|
- `BRIDGE_BASE_URL` and `BRIDGE_TOKEN` if a bridge is configured.
|
||||||
|
|
||||||
|
It does not have direct local CLI access.
|
||||||
|
|
||||||
|
### Self-hosted bridge
|
||||||
|
|
||||||
|
Trusted local integration process. It has:
|
||||||
|
|
||||||
|
- Access to `hermes` binary and `HERMES_HOME`.
|
||||||
|
- Optional `API_SERVER_KEY` only if a future bridge mode calls Hermes API Server for non-Kanban agent actions.
|
||||||
|
- A bridge token accepted only from the BFF/reverse proxy.
|
||||||
|
|
||||||
|
### Hermes API Server
|
||||||
|
|
||||||
|
Powerful agent API. Do not expose directly to the Mini App browser for community default. If users expose it, they must keep bearer auth server-side and set narrow CORS only when absolutely needed.
|
||||||
|
|
||||||
|
## 7. Authentication model
|
||||||
|
|
||||||
|
### Telegram initData validation
|
||||||
|
|
||||||
|
Recommended request from frontend to BFF:
|
||||||
|
|
||||||
|
```http
|
||||||
|
Authorization: tma <raw initData>
|
||||||
|
```
|
||||||
|
|
||||||
|
Server validation steps:
|
||||||
|
|
||||||
|
1. Parse query-string-like init data.
|
||||||
|
2. Remove and store `hash`.
|
||||||
|
3. Sort remaining key-value pairs alphabetically.
|
||||||
|
4. Join as `key=value` lines separated by `\n`.
|
||||||
|
5. Derive secret key: HMAC-SHA256 with key `WebAppData` and message `TELEGRAM_BOT_TOKEN`.
|
||||||
|
6. Compute HMAC-SHA256 of the data-check string using the derived key.
|
||||||
|
7. Compare hex digest to received `hash` using constant-time comparison.
|
||||||
|
8. Reject missing/invalid `auth_date`, expired data, missing user, or malformed JSON.
|
||||||
|
9. Apply allowlist/RBAC: `TELEGRAM_ALLOWED_USER_IDS`, optional `TELEGRAM_ADMIN_USER_IDS`.
|
||||||
|
10. Issue a short-lived app session cookie/JWT or validate `initData` on every API request.
|
||||||
|
|
||||||
|
Recommended TTL: 1 hour for write actions, configurable up to 24 hours for read-only dashboards.
|
||||||
|
|
||||||
|
### App roles
|
||||||
|
|
||||||
|
Minimum roles:
|
||||||
|
|
||||||
|
- `viewer`: boards/tasks read-only, logs redacted/truncated.
|
||||||
|
- `operator`: create/comment/assign/promote/block/unblock/archive/dispatch.
|
||||||
|
- `admin`: configure bridge target, manage board allowlist, view diagnostics.
|
||||||
|
|
||||||
|
Initial open-source release may implement allowlist-only authorization with `operator` for all allowed users and document RBAC as planned, but the API contract should reserve `role` fields now.
|
||||||
|
|
||||||
|
### Bridge authentication
|
||||||
|
|
||||||
|
Recommended:
|
||||||
|
|
||||||
|
- `Authorization: Bearer <bridge-token>` between BFF and bridge.
|
||||||
|
- HTTPS at the public edge if bridge is reachable from Vercel.
|
||||||
|
- Optional reverse-proxy IP allowlist, Cloudflare Access, Tailscale, or mTLS for advanced users.
|
||||||
|
- Bridge should reject requests without `X-Request-Id` and should log audit metadata without storing Telegram raw initData.
|
||||||
|
|
||||||
|
## 8. Threat model
|
||||||
|
|
||||||
|
| Threat | Risk | Mitigation |
|
||||||
|
|---|---|---|
|
||||||
|
| Browser steals Hermes API key | Full agent/tool compromise | Never put `API_SERVER_KEY` or bridge token in frontend env. Use BFF. |
|
||||||
|
| Forged Telegram user | Unauthorized board control | Validate initData server-side using official HMAC flow and max age. |
|
||||||
|
| Replay of old initData | Unauthorized action reuse | Enforce `auth_date` TTL; short-lived session; optional nonce/idempotency for writes. |
|
||||||
|
| CLI injection | Arbitrary local commands | Bridge maps endpoints to fixed argv; use `execFile`/`spawn` with `shell:false`; validate task IDs/board slugs. |
|
||||||
|
| Path traversal via board/workspace | Filesystem escape | Accept only Hermes-valid board slugs; never pass raw paths from browser except documented workspace kind with strict validation. |
|
||||||
|
| Leaking secrets in logs/errors | Token disclosure | Redact `token`, `secret`, `password`, `api_key`; truncate logs; avoid returning raw stderr. |
|
||||||
|
| CSRF against BFF | Actions from malicious site | Use SameSite cookies or Authorization header; verify Origin; require Telegram/session auth for writes. |
|
||||||
|
| CORS overexposure | Browser exfiltration | BFF CORS only for app origin; bridge CORS disabled by default. |
|
||||||
|
| SSRF from BFF to arbitrary bridge URL | Internal network probing | `BRIDGE_BASE_URL` env only; no user-provided target URLs. |
|
||||||
|
| Bridge public exposure | Local Hermes takeover | Strong token, HTTPS, rate limiting, reverse-proxy auth, optional local-only mode. |
|
||||||
|
| Denial of service by refresh/log tail | Resource exhaustion | Pagination, `limit` caps, debounce refresh, server-side rate limits, timeouts. |
|
||||||
|
| Confused deputy across boards | User controls wrong board | Server-side board allowlist; all write endpoints include normalized board slug and audit actor. |
|
||||||
|
| Supply-chain compromise | Public package risk | Pin lockfile, Dependabot/Renovate, CI tests, minimal dependencies. |
|
||||||
|
|
||||||
|
## 9. Deploy matrix
|
||||||
|
|
||||||
|
| Mode | Frontend | BFF | Bridge | Hermes access | Recommended for | Notes |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| Vercel + self-hosted bridge | Vercel static Vite | Vercel Node serverless routes | Node bridge on user's server/Pi/VPS | Bridge runs local `hermes kanban` | Community default | Best UX for Telegram HTTPS and simple updates. Requires exposing bridge securely. |
|
||||||
|
| Docker Compose single host | Containerized static frontend served by BFF or Nginx | Node BFF container | Node bridge container or same process | Bind-mount Hermes home / host `hermes` | Self-hosters | Strong docs needed for volumes and user permissions. Avoid mounting Docker socket. |
|
||||||
|
| Local dev | Vite dev server | Local Node BFF | Local bridge | Local `hermes kanban` | Contributors | Supports Telegram mock/dev auth. No public exposure. |
|
||||||
|
| Fully static Vercel only | Vercel static | None | None | None | Demo/readme only | Cannot control real Hermes. Can show mock data only. |
|
||||||
|
| Browser -> Hermes API direct | Static or local | None | None | Hermes API Server over HTTPS | Not recommended | Exposes bearer token to browser unless using a trusted private network. Document as unsafe for public/community default. |
|
||||||
|
| Future native Hermes Kanban API | Vercel static | Vercel BFF | Optional/none | BFF calls official `/api/kanban` | Future | Adopt only when official Hermes exposes least-privilege Kanban CRUD separate from full agent API. |
|
||||||
|
|
||||||
|
## 10. API contract for v1
|
||||||
|
|
||||||
|
All public frontend calls go to the BFF under `/api/*`. The BFF calls the bridge under `/bridge/v1/*` or equivalent. The frontend should not know whether data came from a bridge or future native Hermes API.
|
||||||
|
|
||||||
|
### Common headers
|
||||||
|
|
||||||
|
Frontend -> BFF:
|
||||||
|
|
||||||
|
```http
|
||||||
|
Authorization: tma <raw initData>
|
||||||
|
Content-Type: application/json
|
||||||
|
X-Request-Id: <uuid>
|
||||||
|
```
|
||||||
|
|
||||||
|
BFF -> Bridge:
|
||||||
|
|
||||||
|
```http
|
||||||
|
Authorization: Bearer <bridge-token>
|
||||||
|
Content-Type: application/json
|
||||||
|
X-Request-Id: <uuid>
|
||||||
|
X-Actor-Telegram-Id: <id>
|
||||||
|
X-Actor-Role: viewer|operator|admin
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common response envelope
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type ApiOk<T> = {
|
||||||
|
ok: true;
|
||||||
|
data: T;
|
||||||
|
requestId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ApiError = {
|
||||||
|
ok: false;
|
||||||
|
error: {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
details?: unknown;
|
||||||
|
};
|
||||||
|
requestId: string;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Core models
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type Board = {
|
||||||
|
slug: string;
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
icon?: string;
|
||||||
|
current?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TaskStatus = 'triage' | 'todo' | 'ready' | 'running' | 'blocked' | 'done' | 'archived';
|
||||||
|
|
||||||
|
type TaskSummary = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
status: TaskStatus;
|
||||||
|
assignee?: string | null;
|
||||||
|
priority?: number | null;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
blockedReason?: string | null;
|
||||||
|
parentIds?: string[];
|
||||||
|
childIds?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type TaskDetail = TaskSummary & {
|
||||||
|
body?: string | null;
|
||||||
|
comments?: TaskComment[];
|
||||||
|
runs?: TaskRun[];
|
||||||
|
events?: TaskEvent[];
|
||||||
|
attachments?: TaskAttachment[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type TaskComment = {
|
||||||
|
id?: string;
|
||||||
|
author?: string;
|
||||||
|
body: string;
|
||||||
|
createdAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TaskRun = {
|
||||||
|
id?: string;
|
||||||
|
status?: string;
|
||||||
|
startedAt?: string;
|
||||||
|
finishedAt?: string;
|
||||||
|
logAvailable?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TaskEvent = {
|
||||||
|
id?: string;
|
||||||
|
type: string;
|
||||||
|
message?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TaskAttachment = {
|
||||||
|
id: string;
|
||||||
|
filename: string;
|
||||||
|
sizeBytes?: number;
|
||||||
|
mimeType?: string;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### BFF endpoints
|
||||||
|
|
||||||
|
Read:
|
||||||
|
|
||||||
|
- `GET /api/health` -> `{ status, version, bridge: { configured, reachable } }`
|
||||||
|
- `GET /api/me` -> current Telegram user + role + auth expiry
|
||||||
|
- `GET /api/boards` -> `Board[]`
|
||||||
|
- `GET /api/boards/:board/tasks?status=&assignee=&q=&limit=&cursor=` -> paginated `TaskSummary[]` grouped client-side or server-side
|
||||||
|
- `GET /api/boards/:board/tasks/:taskId` -> `TaskDetail`
|
||||||
|
- `GET /api/boards/:board/tasks/:taskId/log?lines=200` -> redacted log tail
|
||||||
|
- `GET /api/boards/:board/events/stream` -> SSE for future live updates; polling fallback required
|
||||||
|
|
||||||
|
Write:
|
||||||
|
|
||||||
|
- `POST /api/boards/:board/tasks`
|
||||||
|
- `POST /api/boards/:board/tasks/:taskId/comment`
|
||||||
|
- `POST /api/boards/:board/tasks/:taskId/assign`
|
||||||
|
- `POST /api/boards/:board/tasks/:taskId/promote`
|
||||||
|
- `POST /api/boards/:board/tasks/:taskId/block`
|
||||||
|
- `POST /api/boards/:board/tasks/:taskId/unblock`
|
||||||
|
- `POST /api/boards/:board/tasks/:taskId/archive`
|
||||||
|
- `POST /api/boards/:board/dispatch`
|
||||||
|
|
||||||
|
Write payload examples:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "Implement mobile task drawer",
|
||||||
|
"body": "Acceptance criteria...",
|
||||||
|
"assignee": "developer",
|
||||||
|
"priority": 3,
|
||||||
|
"workspaceKind": "worktree",
|
||||||
|
"workspacePath": null,
|
||||||
|
"goalMode": false,
|
||||||
|
"maxRuntimeSeconds": 3600,
|
||||||
|
"idempotencyKey": "optional-client-generated-key"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "body": "Looks good, unblock after config is set." }
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "assignee": "reviewer" }
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "reason": "Needs maintainer decision on API shape." }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bridge endpoint behavior
|
||||||
|
|
||||||
|
Bridge can mirror BFF paths under `/bridge/v1/boards/...`, but it must be internal-only. It should return the same envelope. It must:
|
||||||
|
|
||||||
|
- Validate all slugs and IDs before spawning Hermes CLI.
|
||||||
|
- Use a command allowlist, not arbitrary pass-through.
|
||||||
|
- Set `HERMES_HOME` from env, not request payload.
|
||||||
|
- Set `HERMES_KANBAN_BOARD` or `--board` from validated board slug.
|
||||||
|
- Enforce timeouts per command.
|
||||||
|
- Redact and normalize errors.
|
||||||
|
|
||||||
|
## 11. UX requirements
|
||||||
|
|
||||||
|
Telegram Mini App:
|
||||||
|
|
||||||
|
- Respect Telegram theme variables and safe areas.
|
||||||
|
- Use Telegram-style components for lists, section headers, buttons, badges, bottom sheets/drawers, destructive confirmations.
|
||||||
|
- Use haptic feedback only for meaningful actions if SDK supports it.
|
||||||
|
- `MainButton`/bottom action area for primary task action on mobile.
|
||||||
|
- HashRouter or equivalent router compatible with Telegram webview and static hosting.
|
||||||
|
|
||||||
|
Browser/mobile:
|
||||||
|
|
||||||
|
- Responsive board: columns on desktop, status tabs/list on mobile.
|
||||||
|
- Fast read path: skeleton loading, optimistic comment append only after server accepts writes or clearly mark pending.
|
||||||
|
- Polling fallback; SSE enhancement later.
|
||||||
|
- Accessible color contrast and keyboard navigation in browser.
|
||||||
|
|
||||||
|
Community docs/quality:
|
||||||
|
|
||||||
|
- `.env.example` only placeholders.
|
||||||
|
- No real bot tokens, Hermes keys, domains, local IPs, or passwords.
|
||||||
|
- README in English plus Russian quickstart or `README.ru.md`.
|
||||||
|
- `SECURITY.md`, `CONTRIBUTING.md`, CI, tests, build checks.
|
||||||
|
|
||||||
|
## 12. Documentation outline
|
||||||
|
|
||||||
|
### English docs
|
||||||
|
|
||||||
|
1. `README.md`
|
||||||
|
- What is Hermes Kanban Mini App?
|
||||||
|
- Screenshots/GIFs
|
||||||
|
- Architecture summary
|
||||||
|
- Quickstart matrix: Vercel + bridge, Docker Compose, local dev
|
||||||
|
- Security warning: static frontend cannot run Hermes CLI
|
||||||
|
- Links to full docs
|
||||||
|
2. `docs/installation.en.md`
|
||||||
|
- Prerequisites: Node.js, Hermes Agent, Telegram bot, HTTPS URL
|
||||||
|
- Create Telegram bot and configure Mini App domain
|
||||||
|
- Vercel frontend/BFF deploy
|
||||||
|
- Self-hosted bridge setup
|
||||||
|
- Docker Compose setup
|
||||||
|
- Local dev setup with mock Telegram auth
|
||||||
|
3. `docs/configuration.en.md`
|
||||||
|
- All env vars
|
||||||
|
- Auth modes
|
||||||
|
- Board allowlist
|
||||||
|
- Rate limit settings
|
||||||
|
- Reverse proxy examples
|
||||||
|
4. `docs/security.en.md`
|
||||||
|
- Threat model
|
||||||
|
- Token handling
|
||||||
|
- Telegram initData validation
|
||||||
|
- Bridge exposure hardening
|
||||||
|
- Responsible disclosure
|
||||||
|
5. `docs/api.en.md`
|
||||||
|
- BFF and bridge endpoint contracts
|
||||||
|
- Error codes
|
||||||
|
- SSE/polling behavior
|
||||||
|
6. `docs/migration-from-fastapi-prototype.en.md`
|
||||||
|
- Mapping current prototype endpoints to new TS BFF/bridge
|
||||||
|
- Removed assumptions
|
||||||
|
- Compatibility notes
|
||||||
|
|
||||||
|
### Russian docs
|
||||||
|
|
||||||
|
1. `README.ru.md`
|
||||||
|
- Что это и кому нужно
|
||||||
|
- Рекомендуемая схема установки
|
||||||
|
- Главное предупреждение про Vercel/static и локальный Hermes CLI
|
||||||
|
2. `docs/installation.ru.md`
|
||||||
|
- Telegram BotFather
|
||||||
|
- Vercel
|
||||||
|
- self-hosted bridge
|
||||||
|
- Docker Compose
|
||||||
|
- локальный dev
|
||||||
|
3. `docs/configuration.ru.md`
|
||||||
|
- Переменные окружения
|
||||||
|
- режимы авторизации
|
||||||
|
- allowlist пользователей/досок
|
||||||
|
4. `docs/security.ru.md`
|
||||||
|
- модель угроз
|
||||||
|
- где хранить токены
|
||||||
|
- как безопасно открыть bridge
|
||||||
|
5. `docs/api.ru.md`
|
||||||
|
- контракт API
|
||||||
|
- ошибки
|
||||||
|
6. `docs/migration-from-fastapi-prototype.ru.md`
|
||||||
|
- план миграции с текущего прототипа
|
||||||
|
|
||||||
|
## 13. Environment variables for the new stack
|
||||||
|
|
||||||
|
Frontend build-time public vars:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VITE_APP_NAME=Hermes Kanban
|
||||||
|
VITE_BFF_BASE_URL= # empty for same-origin Vercel deployment
|
||||||
|
VITE_TELEGRAM_BOT_USERNAME= # optional display/link only, not token
|
||||||
|
```
|
||||||
|
|
||||||
|
Vercel BFF server vars:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TELEGRAM_BOT_TOKEN= # secret, server-only
|
||||||
|
TELEGRAM_ALLOWED_USER_IDS= # comma-separated Telegram IDs
|
||||||
|
TELEGRAM_ADMIN_USER_IDS= # optional
|
||||||
|
SESSION_SECRET= # random 32+ bytes
|
||||||
|
BRIDGE_BASE_URL= # https://bridge.example.com
|
||||||
|
BRIDGE_TOKEN= # secret, server-to-server
|
||||||
|
ALLOWED_ORIGINS= # exact frontend origins
|
||||||
|
AUTH_MAX_AGE_SECONDS=3600
|
||||||
|
READ_AUTH_MAX_AGE_SECONDS=86400
|
||||||
|
RATE_LIMIT_PER_MINUTE=60
|
||||||
|
```
|
||||||
|
|
||||||
|
Bridge server vars:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BRIDGE_TOKEN= # must match BFF
|
||||||
|
HERMES_BIN=hermes
|
||||||
|
HERMES_HOME= # target Hermes profile home
|
||||||
|
HERMES_KANBAN_BOARD=default
|
||||||
|
BRIDGE_HOST=127.0.0.1
|
||||||
|
BRIDGE_PORT=8787
|
||||||
|
BRIDGE_ALLOWED_BOARDS=default
|
||||||
|
COMMAND_TIMEOUT_SECONDS=30
|
||||||
|
LOG_TAIL_MAX_LINES=500
|
||||||
|
```
|
||||||
|
|
||||||
|
Docker Compose vars should mirror the same names. `.env` must stay gitignored; `.env.example` must contain placeholders only.
|
||||||
|
|
||||||
|
## 14. Migration plan from current FastAPI prototype
|
||||||
|
|
||||||
|
Do not modify the current Python implementation until this ADR is accepted.
|
||||||
|
|
||||||
|
Phase 0: Freeze and document
|
||||||
|
|
||||||
|
- Keep current prototype uncommitted as reference only.
|
||||||
|
- Add this ADR.
|
||||||
|
- Record endpoint inventory and data shapes from `backend/app/main.py`, `adapter.py`, `auth.py`, and frontend calls.
|
||||||
|
|
||||||
|
Phase 1: Extract contracts
|
||||||
|
|
||||||
|
- Create shared TypeScript schemas for Board, TaskSummary, TaskDetail, API envelope, and write payloads.
|
||||||
|
- Write contract tests against fixtures copied from current prototype responses.
|
||||||
|
- Define error code taxonomy.
|
||||||
|
|
||||||
|
Phase 2: Build TypeScript bridge
|
||||||
|
|
||||||
|
- Implement Node bridge with fixed command allowlist matching current `HermesCliKanbanAdapter` behavior.
|
||||||
|
- Port Telegram-independent validation helpers: board slug, task ID, safe log limits, error redaction.
|
||||||
|
- Add tests that assert `shell:false` / `execFile` usage and reject invalid IDs/slugs.
|
||||||
|
- Verify against local `hermes kanban` commands.
|
||||||
|
|
||||||
|
Phase 3: Build Vercel BFF
|
||||||
|
|
||||||
|
- Implement Telegram initData validation using a maintained Node package such as `@tma.js/init-data-node` or a small audited HMAC helper with tests from Telegram examples.
|
||||||
|
- Add allowlist auth and bridge proxy endpoints.
|
||||||
|
- Add rate limiting and CORS/origin checks.
|
||||||
|
|
||||||
|
Phase 4: Rebuild frontend UX
|
||||||
|
|
||||||
|
- Keep React + TypeScript + Vite.
|
||||||
|
- Replace ad-hoc Telegram integration with Telegram Apps SDK React package.
|
||||||
|
- Add Telegram UI components, mobile task drawer, board status tabs, desktop columns.
|
||||||
|
- Use BFF API only; no direct bridge/Hermes calls from browser.
|
||||||
|
|
||||||
|
Phase 5: Docs and release hardening
|
||||||
|
|
||||||
|
- Write EN/RU docs from outline.
|
||||||
|
- Add `.env.example`, `SECURITY.md`, `CONTRIBUTING.md`, CI.
|
||||||
|
- Add unit tests, contract tests, frontend tests, build check.
|
||||||
|
- Add Docker Compose example.
|
||||||
|
- Add Vercel deployment guide.
|
||||||
|
|
||||||
|
Phase 6: Optional legacy compatibility
|
||||||
|
|
||||||
|
- Either remove Python prototype from the public release or move it to `legacy/fastapi-prototype` with clear unsupported status.
|
||||||
|
- Do not publish both as equal recommended paths; it confuses users.
|
||||||
|
|
||||||
|
## 15. Consequences
|
||||||
|
|
||||||
|
Positive:
|
||||||
|
|
||||||
|
- Clear Vercel-first story without lying about local CLI access.
|
||||||
|
- Secrets stay server-side.
|
||||||
|
- TypeScript types can be shared across frontend, BFF, and bridge.
|
||||||
|
- Bridge is optional but honest: users who want real Hermes control must run a trusted server near Hermes.
|
||||||
|
- Future Hermes native Kanban HTTP API can replace bridge internals without changing frontend UX.
|
||||||
|
|
||||||
|
Negative/tradeoffs:
|
||||||
|
|
||||||
|
- Requires two deploy targets for real use: Vercel app and bridge.
|
||||||
|
- Public bridge exposure needs careful docs.
|
||||||
|
- Slightly more moving parts than a single FastAPI container.
|
||||||
|
- Until Hermes exposes first-class Kanban HTTP endpoints, the bridge still depends on CLI JSON behavior.
|
||||||
|
|
||||||
|
## 16. Rejected alternatives
|
||||||
|
|
||||||
|
### Static Vercel app directly runs Hermes CLI
|
||||||
|
|
||||||
|
Rejected. Browsers/static hosting cannot execute the user's local CLI. Any workaround would require unsafe local agents or browser extensions and is not community-ready.
|
||||||
|
|
||||||
|
### Static app directly calls Hermes API Server with API key
|
||||||
|
|
||||||
|
Rejected for public/community default. Hermes API Server bearer key controls a full agent toolset including terminal; exposing it to JavaScript is a critical security issue.
|
||||||
|
|
||||||
|
### Keep single Python/FastAPI backend as recommended public stack
|
||||||
|
|
||||||
|
Rejected as recommended stack. It works locally and may be a useful prototype, but it conflicts with TypeScript/Vercel preference and mixes trust boundaries.
|
||||||
|
|
||||||
|
### Full Next.js monolith
|
||||||
|
|
||||||
|
Not selected. Next.js could host frontend+BFF on Vercel, but Vite static + small serverless BFF is simpler, lighter, and closer to official Telegram Mini Apps React template patterns. If future SSR/auth needs grow, migration to Next.js remains possible.
|
||||||
|
|
||||||
|
### Bridge reads `kanban.db` directly
|
||||||
|
|
||||||
|
Rejected for v1. Direct DB reads couple the project to Hermes internals and risk schema drift. Use official CLI behavior first; switch to official HTTP API when available.
|
||||||
|
|
||||||
|
## 17. Precise next implementation milestones
|
||||||
|
|
||||||
|
M1. Contract foundation
|
||||||
|
|
||||||
|
- Add `packages/shared` or `src/shared` TypeScript schemas.
|
||||||
|
- Add API envelope and error code tests.
|
||||||
|
- No UI rewrite yet.
|
||||||
|
|
||||||
|
M2. Bridge MVP
|
||||||
|
|
||||||
|
- Create `apps/bridge` TypeScript service.
|
||||||
|
- Implement `GET /health`, `GET /bridge/v1/boards`, `GET /bridge/v1/boards/:board/tasks`, `GET /bridge/v1/boards/:board/tasks/:taskId`.
|
||||||
|
- Use fixed `execFile`/`spawn` calls to `hermes kanban` with tests.
|
||||||
|
|
||||||
|
M3. BFF auth MVP
|
||||||
|
|
||||||
|
- Create Vercel API routes.
|
||||||
|
- Validate Telegram initData in server code.
|
||||||
|
- Proxy read endpoints to bridge.
|
||||||
|
- Add auth fixtures/tests.
|
||||||
|
|
||||||
|
M4. Frontend read-only UX
|
||||||
|
|
||||||
|
- Migrate current React UI to new BFF contract.
|
||||||
|
- Add Telegram SDK initialization and Telegram UI theme handling.
|
||||||
|
- Ship read-only boards/tasks/log tail.
|
||||||
|
|
||||||
|
M5. Write actions
|
||||||
|
|
||||||
|
- Add create/comment/assign/promote/block/unblock/archive/dispatch.
|
||||||
|
- Add confirmations and role checks.
|
||||||
|
- Add audit log fields passed to bridge.
|
||||||
|
|
||||||
|
M6. Release docs and CI
|
||||||
|
|
||||||
|
- EN/RU install docs.
|
||||||
|
- `.env.example` for frontend, BFF, bridge, Docker Compose.
|
||||||
|
- `SECURITY.md`, `CONTRIBUTING.md`.
|
||||||
|
- CI: typecheck, lint, unit tests, frontend build, bridge tests.
|
||||||
|
|
||||||
|
M7. Optional live updates
|
||||||
|
|
||||||
|
- SSE endpoint from BFF to frontend.
|
||||||
|
- Bridge polling or future Hermes dashboard/events integration.
|
||||||
|
- Graceful fallback to polling.
|
||||||
|
|
||||||
|
## 18. Acceptance criteria for starting app-code rewrite
|
||||||
|
|
||||||
|
Do not write implementation code until these are accepted:
|
||||||
|
|
||||||
|
- This ADR stack decision is approved.
|
||||||
|
- Maintainer confirms whether bridge is a separate `apps/bridge` service or same package as BFF with a runtime mode switch.
|
||||||
|
- Maintainer confirms whether the public repo should delete the Python prototype or keep it under `legacy/`.
|
||||||
|
- First milestone is limited to contracts + tests + scaffold, not feature-complete UI.
|
||||||
41
frontend/Dockerfile
Normal file
41
frontend/Dockerfile
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Frontend — Multi-stage Docker build
|
||||||
|
# Stage 1: install deps + build with Vite
|
||||||
|
# Stage 2: serve static with nginx:alpine
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# ── Stage 1: Build ─────────────────────────────────────────
|
||||||
|
FROM node:22-alpine AS build
|
||||||
|
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy workspace scaffolding
|
||||||
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||||
|
COPY packages/contracts/package.json packages/contracts/
|
||||||
|
COPY frontend/package.json frontend/
|
||||||
|
|
||||||
|
# Install all dependencies
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# Copy source
|
||||||
|
COPY packages/contracts/ packages/contracts/
|
||||||
|
COPY frontend/ frontend/
|
||||||
|
|
||||||
|
# Build contracts (frontend depends on @kanban/contracts), then frontend
|
||||||
|
RUN pnpm --filter @kanban/contracts build && \
|
||||||
|
pnpm --filter frontend build
|
||||||
|
|
||||||
|
# ── Stage 2: Serve ─────────────────────────────────────────
|
||||||
|
FROM nginx:alpine AS runtime
|
||||||
|
|
||||||
|
# Remove default nginx config
|
||||||
|
RUN rm -f /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
# Copy custom nginx config
|
||||||
|
COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
# Copy built frontend
|
||||||
|
COPY --from=build /app/frontend/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|
@ -1 +1,12 @@
|
||||||
<!doctype html><html lang="en"><head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>Hermes Kanban</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
|
<title>Hermes Kanban</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
|
||||||
39
frontend/nginx.conf
Normal file
39
frontend/nginx.conf
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# nginx.conf — Frontend container configuration
|
||||||
|
# Serves Vite SPA and proxies /bridge to the bridge service
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Proxy /bridge requests to the bridge Express service
|
||||||
|
location /bridge/ {
|
||||||
|
proxy_pass http://bridge:3456/bridge/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
|
||||||
|
# Timeouts for slow hermes CLI calls
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
proxy_send_timeout 60s;
|
||||||
|
proxy_connect_timeout 10s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA fallback — serve index.html for client-side routes
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cache static assets aggressively (Vite hashes filenames)
|
||||||
|
location /assets/ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
}
|
||||||
2525
frontend/package-lock.json
generated
2525
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1 +1,29 @@
|
||||||
{"scripts":{"dev":"vite --host 0.0.0.0","build":"tsc -b && vite build","preview":"vite preview --host 0.0.0.0","test":"vitest run --environment jsdom"},"dependencies":{"@vitejs/plugin-react":"latest","vite":"latest","typescript":"latest","react":"latest","react-dom":"latest","lucide-react":"latest"},"devDependencies":{"vitest":"latest","jsdom":"latest","@testing-library/react":"latest","@testing-library/jest-dom":"latest","@testing-library/user-event":"latest","@types/react":"latest","@types/react-dom":"latest"}}
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 0.0.0.0",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview --host 0.0.0.0",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"clean": "rm -rf dist"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@kanban/contracts": "workspace:*",
|
||||||
|
"@telegram-apps/sdk-react": "^3.3.9",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.12",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.49",
|
||||||
|
"tailwindcss": "^3.4.16",
|
||||||
|
"typescript": "^5.6.3",
|
||||||
|
"vite": "^6.0.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -1,186 +1,253 @@
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { RefreshCw, Plus, Send } from 'lucide-react'
|
import type { Board, TaskSummary, TaskDetail, TaskListData, CreateTaskPayload, CommentPayload } from '@kanban/contracts'
|
||||||
import { api } from './api'
|
import { DEFAULT_COLUMNS } from '@kanban/contracts'
|
||||||
import { applyTelegramTheme } from './telegram'
|
import { initTelegram, applyTheme, hapticFeedback, showBackButton, hideBackButton } from './telegram'
|
||||||
import type { Event, Run, Task, TaskGroups } from './types'
|
import { fetchBoards, fetchTasks, fetchTask, createTask, addComment } from './api/client'
|
||||||
|
import BoardSelector from './components/BoardSelector'
|
||||||
|
import KanbanBoard from './components/KanbanBoard'
|
||||||
|
import TaskDetailModal from './components/TaskDetailModal'
|
||||||
|
import CreateTaskForm from './components/CreateTaskForm'
|
||||||
|
|
||||||
const STATUSES = ['triage', 'todo', 'ready', 'running', 'blocked', 'done', 'archived']
|
type View = 'boards' | 'board' | 'task' | 'create'
|
||||||
|
|
||||||
type Detail = {
|
|
||||||
task?: Task
|
|
||||||
events: Event[]
|
|
||||||
runs: Run[]
|
|
||||||
log: string
|
|
||||||
missingLog: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
function ErrorBanner({ message }: { message: string }) {
|
|
||||||
return <div className="error" role="alert">{message}</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
function TaskCard({ task, onOpen }: { task: Task; onOpen: (task: Task) => void }) {
|
|
||||||
return (
|
|
||||||
<button className="task-card" onClick={() => onOpen(task)}>
|
|
||||||
<span className="task-id">{task.id}</span>
|
|
||||||
<strong>{task.title}</strong>
|
|
||||||
<span className="status-pill">status: {task.status}</span>
|
|
||||||
<span>{task.assignee || 'unassigned'}</span>
|
|
||||||
<span>priority {task.priority ?? 0}</span>
|
|
||||||
{task.age && <span>{task.age}</span>}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CreateTask({ onCreated }: { onCreated: () => void }) {
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
const [title, setTitle] = useState('')
|
|
||||||
const [body, setBody] = useState('')
|
|
||||||
const [assignee, setAssignee] = useState('')
|
|
||||||
const [priority, setPriority] = useState(0)
|
|
||||||
const [workspaceKind, setWorkspaceKind] = useState('scratch')
|
|
||||||
const [goalMode, setGoalMode] = useState(false)
|
|
||||||
const [maxRuntime, setMaxRuntime] = useState('')
|
|
||||||
const [error, setError] = useState('')
|
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
if (!title.trim()) {
|
|
||||||
setError('Title is required')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setError('')
|
|
||||||
await api.create({
|
|
||||||
title,
|
|
||||||
body,
|
|
||||||
assignee: assignee || null,
|
|
||||||
priority,
|
|
||||||
workspace_kind: workspaceKind,
|
|
||||||
goal_mode: goalMode,
|
|
||||||
max_runtime_seconds: maxRuntime ? Number(maxRuntime) : null,
|
|
||||||
})
|
|
||||||
setOpen(false)
|
|
||||||
setTitle('')
|
|
||||||
setBody('')
|
|
||||||
onCreated()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!open) return <button className="primary" onClick={() => setOpen(true)}><Plus size={16} />Create task</button>
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="modal-card">
|
|
||||||
<h2>Create task</h2>
|
|
||||||
{error && <ErrorBanner message={error} />}
|
|
||||||
<input placeholder="Title" value={title} onChange={(event) => setTitle(event.target.value)} />
|
|
||||||
<textarea placeholder="Body" value={body} onChange={(event) => setBody(event.target.value)} />
|
|
||||||
<input placeholder="Assignee" value={assignee} onChange={(event) => setAssignee(event.target.value)} />
|
|
||||||
<input aria-label="Priority" type="number" value={priority} onChange={(event) => setPriority(Number(event.target.value))} />
|
|
||||||
<select value={workspaceKind} onChange={(event) => setWorkspaceKind(event.target.value)}>
|
|
||||||
<option value="scratch">scratch</option>
|
|
||||||
<option value="dir">dir</option>
|
|
||||||
<option value="worktree">worktree</option>
|
|
||||||
</select>
|
|
||||||
<label className="row"><input type="checkbox" checked={goalMode} onChange={(event) => setGoalMode(event.target.checked)} /> goal mode</label>
|
|
||||||
<input placeholder="Max runtime seconds" value={maxRuntime} onChange={(event) => setMaxRuntime(event.target.value)} />
|
|
||||||
<div className="row">
|
|
||||||
<button onClick={submit}>Create</button>
|
|
||||||
<button className="ghost" onClick={() => setOpen(false)}>Cancel</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DetailDrawer({ selected, detail, onClose, onRefresh }: { selected: Task | null; detail: Detail | null; onClose: () => void; onRefresh: () => void }) {
|
|
||||||
const [comment, setComment] = useState('')
|
|
||||||
const [assign, setAssign] = useState('')
|
|
||||||
const [blockReason, setBlockReason] = useState('')
|
|
||||||
if (!selected) return null
|
|
||||||
const task = detail?.task || selected
|
|
||||||
async function addComment() {
|
|
||||||
if (!comment.trim()) return
|
|
||||||
await api.comment(selected!.id, comment)
|
|
||||||
setComment('')
|
|
||||||
onRefresh()
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<aside className="drawer">
|
|
||||||
<button className="close" onClick={onClose}>×</button>
|
|
||||||
<p className="task-id">{task.id}</p>
|
|
||||||
<h2>{task.title}</h2>
|
|
||||||
<p className="body">{task.body || 'No body'}</p>
|
|
||||||
<div className="actions">
|
|
||||||
<button onClick={() => api.action(task.id, 'promote').then(onRefresh)}>Promote</button>
|
|
||||||
<button onClick={() => api.action(task.id, 'unblock').then(onRefresh)}>Unblock</button>
|
|
||||||
<button onClick={() => api.action(task.id, 'archive').then(onRefresh)}>Archive</button>
|
|
||||||
</div>
|
|
||||||
<div className="row"><input placeholder="Assign to" value={assign} onChange={(event) => setAssign(event.target.value)} /><button onClick={() => api.assign(task.id, assign).then(onRefresh)}>Assign</button></div>
|
|
||||||
<div className="row"><input placeholder="Block reason" value={blockReason} onChange={(event) => setBlockReason(event.target.value)} /><button onClick={() => api.block(task.id, blockReason).then(onRefresh)}>Block</button></div>
|
|
||||||
<section><h3>Comments</h3><div className="row"><input placeholder="Comment" value={comment} onChange={(event) => setComment(event.target.value)} /><button onClick={addComment}><Send size={14} /></button></div></section>
|
|
||||||
<section><h3>Events</h3><pre>{JSON.stringify(detail?.events || [], null, 2)}</pre></section>
|
|
||||||
<section><h3>Runs</h3><pre>{JSON.stringify(detail?.runs || [], null, 2)}</pre></section>
|
|
||||||
<section><h3>Log</h3><button onClick={onRefresh}>Refresh log</button><pre>{detail?.missingLog ? 'No log yet' : detail?.log}</pre></section>
|
|
||||||
</aside>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [groups, setGroups] = useState<TaskGroups>({})
|
const [view, setView] = useState<View>('boards')
|
||||||
const [error, setError] = useState('')
|
const [boards, setBoards] = useState<Board[]>([])
|
||||||
|
const [selectedBoard, setSelectedBoard] = useState<Board | null>(null)
|
||||||
|
const [tasks, setTasks] = useState<TaskListData | null>(null)
|
||||||
|
const [selectedTask, setSelectedTask] = useState<TaskDetail | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [selected, setSelected] = useState<Task | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [detail, setDetail] = useState<Detail | null>(null)
|
|
||||||
const hasRunningTasks = useMemo(() => (groups.running || []).length > 0, [groups])
|
|
||||||
|
|
||||||
async function loadBoard() {
|
// Initialize Telegram on mount
|
||||||
|
useEffect(() => {
|
||||||
|
initTelegram()
|
||||||
|
applyTheme()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Load boards
|
||||||
|
const loadBoards = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await api.tasks()
|
setLoading(true)
|
||||||
setGroups(data.groups)
|
setError(null)
|
||||||
setError('')
|
const data = await fetchBoards()
|
||||||
|
setBoards(data)
|
||||||
|
// Auto-select if only one board
|
||||||
|
if (data.length === 1) {
|
||||||
|
setSelectedBoard(data[0])
|
||||||
|
setView('board')
|
||||||
|
} else {
|
||||||
|
setView('boards')
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load board')
|
setError(err instanceof Error ? err.message : 'Failed to load boards')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
async function openTask(task: Task) {
|
|
||||||
setSelected(task)
|
|
||||||
const [taskData, events, runs, log] = await Promise.all([api.task(task.id), api.events(task.id), api.runs(task.id), api.log(task.id)])
|
|
||||||
setDetail({ task: taskData.task, events: events.events, runs: runs.runs, log: log.log, missingLog: log.missing })
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshSelected() {
|
|
||||||
await loadBoard()
|
|
||||||
if (selected) await openTask(selected)
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
applyTelegramTheme()
|
|
||||||
loadBoard()
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Load tasks for board
|
||||||
|
const loadTasks = useCallback(async (board: string) => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
const data = await fetchTasks(board)
|
||||||
|
setTasks(data)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load tasks')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Load task detail
|
||||||
|
const loadTaskDetail = useCallback(async (board: string, taskId: string) => {
|
||||||
|
try {
|
||||||
|
const detail = await fetchTask(board, taskId)
|
||||||
|
setSelectedTask(detail)
|
||||||
|
setView('task')
|
||||||
|
showBackButton(() => {
|
||||||
|
setSelectedTask(null)
|
||||||
|
setView('board')
|
||||||
|
hideBackButton()
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load task')
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Initial load
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interval = window.setInterval(loadBoard, hasRunningTasks ? 2500 : 5000)
|
loadBoards()
|
||||||
return () => window.clearInterval(interval)
|
}, [loadBoards])
|
||||||
}, [hasRunningTasks])
|
|
||||||
|
// Load tasks when board changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedBoard) {
|
||||||
|
loadTasks(selectedBoard.slug)
|
||||||
|
}
|
||||||
|
}, [selectedBoard, loadTasks])
|
||||||
|
|
||||||
|
// Handle board selection
|
||||||
|
const handleSelectBoard = (board: Board) => {
|
||||||
|
hapticFeedback('light')
|
||||||
|
setSelectedBoard(board)
|
||||||
|
setView('board')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle task tap
|
||||||
|
const handleTaskTap = (task: TaskSummary) => {
|
||||||
|
hapticFeedback('light')
|
||||||
|
if (selectedBoard) {
|
||||||
|
loadTaskDetail(selectedBoard.slug, task.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle create task
|
||||||
|
const handleCreateTask = async (payload: CreateTaskPayload) => {
|
||||||
|
if (!selectedBoard) return
|
||||||
|
try {
|
||||||
|
hapticFeedback('medium')
|
||||||
|
await createTask(selectedBoard.slug, payload)
|
||||||
|
setView('board')
|
||||||
|
loadTasks(selectedBoard.slug)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to create task')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle add comment
|
||||||
|
const handleAddComment = async (taskId: string, payload: CommentPayload) => {
|
||||||
|
if (!selectedBoard) return
|
||||||
|
try {
|
||||||
|
hapticFeedback('light')
|
||||||
|
await addComment(selectedBoard.slug, taskId, payload)
|
||||||
|
// Reload task detail
|
||||||
|
const detail = await fetchTask(selectedBoard.slug, taskId)
|
||||||
|
setSelectedTask(detail)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to add comment')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle back
|
||||||
|
const handleBack = () => {
|
||||||
|
hapticFeedback('light')
|
||||||
|
if (view === 'task') {
|
||||||
|
setSelectedTask(null)
|
||||||
|
setView('board')
|
||||||
|
hideBackButton()
|
||||||
|
} else if (view === 'create') {
|
||||||
|
setView('board')
|
||||||
|
hideBackButton()
|
||||||
|
} else if (view === 'board' && boards.length > 1) {
|
||||||
|
setSelectedBoard(null)
|
||||||
|
setView('boards')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle open create form
|
||||||
|
const handleOpenCreate = () => {
|
||||||
|
hapticFeedback('medium')
|
||||||
|
setView('create')
|
||||||
|
showBackButton(handleBack)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (loading && !tasks) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-4"></div>
|
||||||
|
<p className="text-gray-400">Loading…</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
if (error && !tasks) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen p-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-red-400 mb-4">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={loadBoards}
|
||||||
|
className="px-4 py-2 bg-blue-600 rounded-lg text-white"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="app-shell">
|
<div className="min-h-screen flex flex-col">
|
||||||
<header className="topbar">
|
{/* Header */}
|
||||||
<div><p className="eyebrow">Telegram Mini App</p><h1>Hermes Kanban</h1></div>
|
<header className="sticky top-0 z-10 bg-[var(--tg-secondary-bg-color,#1a1f2e)] border-b border-white/10 px-4 py-3">
|
||||||
<div className="row"><CreateTask onCreated={loadBoard} /><button className="ghost" onClick={loadBoard}><RefreshCw size={16} />Refresh</button><button className="ghost" onClick={() => api.dispatch().then(loadBoard)}>Dispatch</button></div>
|
<div className="flex items-center justify-between">
|
||||||
</header>
|
{view !== 'boards' && (
|
||||||
{error && <ErrorBanner message={error} />}
|
<button
|
||||||
{loading ? <p>Loading…</p> : (
|
onClick={handleBack}
|
||||||
<section className="board">
|
className="p-2 -ml-2 text-blue-400"
|
||||||
{STATUSES.map((status) => (
|
>
|
||||||
<div className="column" key={status}>
|
← Back
|
||||||
<h2>{status}</h2>
|
</button>
|
||||||
{(groups[status] || []).map((task) => <TaskCard key={task.id} task={task} onOpen={openTask} />)}
|
)}
|
||||||
{(groups[status] || []).length === 0 && <p className="empty">No tasks</p>}
|
<h1 className="text-lg font-semibold flex-1 text-center">
|
||||||
</div>
|
{view === 'boards' ? 'Select Board' : selectedBoard?.name || selectedBoard?.slug || 'Kanban'}
|
||||||
))}
|
</h1>
|
||||||
</section>
|
{view === 'board' && (
|
||||||
|
<button
|
||||||
|
onClick={handleOpenCreate}
|
||||||
|
className="p-2 -mr-2 text-blue-400 font-medium"
|
||||||
|
>
|
||||||
|
+ New
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{view === 'boards' && <div className="w-10" />}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Error banner */}
|
||||||
|
{error && (
|
||||||
|
<div className="mx-4 mt-3 p-3 bg-red-900/30 border border-red-500/30 rounded-lg text-red-300 text-sm">
|
||||||
|
{error}
|
||||||
|
<button onClick={() => setError(null)} className="ml-2 text-red-400">✕</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<main className="flex-1 overflow-hidden">
|
||||||
|
{view === 'boards' && (
|
||||||
|
<BoardSelector boards={boards} onSelect={handleSelectBoard} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === 'board' && tasks && (
|
||||||
|
<KanbanBoard
|
||||||
|
tasks={tasks.items}
|
||||||
|
columns={DEFAULT_COLUMNS}
|
||||||
|
onTaskTap={handleTaskTap}
|
||||||
|
onRefresh={() => selectedBoard && loadTasks(selectedBoard.slug)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === 'task' && selectedTask && (
|
||||||
|
<TaskDetailModal
|
||||||
|
task={selectedTask}
|
||||||
|
onClose={handleBack}
|
||||||
|
onAddComment={(payload) => handleAddComment(selectedTask.id, payload)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === 'create' && (
|
||||||
|
<CreateTaskForm
|
||||||
|
onSubmit={handleCreateTask}
|
||||||
|
onCancel={handleBack}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<DetailDrawer selected={selected} detail={detail} onClose={() => setSelected(null)} onRefresh={refreshSelected} />
|
|
||||||
</main>
|
</main>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
|
||||||
import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react'
|
|
||||||
import App from '../App'
|
|
||||||
|
|
||||||
const tasksResponse = {
|
|
||||||
tasks: [
|
|
||||||
{ id: 't_1', title: 'Build thing', status: 'ready', assignee: 'developer', priority: 5, age: '1m' },
|
|
||||||
],
|
|
||||||
groups: {
|
|
||||||
triage: [],
|
|
||||||
todo: [],
|
|
||||||
ready: [{ id: 't_1', title: 'Build thing', status: 'ready', assignee: 'developer', priority: 5 }],
|
|
||||||
running: [],
|
|
||||||
blocked: [],
|
|
||||||
done: [],
|
|
||||||
archived: [],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit) => {
|
|
||||||
if (String(url).includes('/api/tasks/t_1/events')) return new Response(JSON.stringify({ events: [{ kind: 'created' }] }))
|
|
||||||
if (String(url).includes('/api/tasks/t_1/runs')) return new Response(JSON.stringify({ runs: [{ id: 1, status: 'completed' }] }))
|
|
||||||
if (String(url).includes('/api/tasks/t_1/log')) return new Response(JSON.stringify({ log: 'tail', missing: false }))
|
|
||||||
if (String(url).endsWith('/api/tasks/t_1')) return new Response(JSON.stringify({ task: { ...tasksResponse.tasks[0], body: 'Full body' } }))
|
|
||||||
if (String(url).includes('/api/tasks') && init?.method === 'POST') return new Response(JSON.stringify({ task: { id: 't_new', title: 'Created', status: 'todo' } }))
|
|
||||||
if (String(url).includes('/api/tasks')) return new Response(JSON.stringify(tasksResponse))
|
|
||||||
return new Response(JSON.stringify({ status: 'ok' }))
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup()
|
|
||||||
vi.unstubAllGlobals()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('App', () => {
|
|
||||||
it('renders board columns', async () => {
|
|
||||||
render(<App />)
|
|
||||||
expect(await screen.findByText('ready')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('blocked')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders task cards with title status and assignee', async () => {
|
|
||||||
render(<App />)
|
|
||||||
expect(await screen.findByText('Build thing')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('developer')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('opens detail drawer and shows body events and log sections', async () => {
|
|
||||||
render(<App />)
|
|
||||||
fireEvent.click(await screen.findByText('Build thing'))
|
|
||||||
expect(await screen.findByText('Full body')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('Events')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('Log')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('create form validates title', async () => {
|
|
||||||
render(<App />)
|
|
||||||
fireEvent.click(await screen.findByText('Create task'))
|
|
||||||
fireEvent.click(screen.getByText('Create'))
|
|
||||||
expect(await screen.findByText('Title is required')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows API error state', async () => {
|
|
||||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ detail: { message: 'boom' } }), { status: 500 })))
|
|
||||||
render(<App />)
|
|
||||||
expect(await screen.findByText(/boom/)).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not crash when Telegram WebApp is absent', async () => {
|
|
||||||
vi.stubGlobal('Telegram', undefined)
|
|
||||||
render(<App />)
|
|
||||||
await waitFor(() => expect(screen.getByText('Hermes Kanban')).toBeInTheDocument())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
import { getTelegramInitData } from './telegram'
|
|
||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_API_BASE_URL || ''
|
|
||||||
|
|
||||||
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
|
||||||
const headers = new Headers(init.headers)
|
|
||||||
headers.set('Content-Type', 'application/json')
|
|
||||||
const initData = getTelegramInitData()
|
|
||||||
if (initData) headers.set('X-Telegram-Init-Data', initData)
|
|
||||||
const response = await fetch(`${API_BASE}${path}`, { ...init, headers })
|
|
||||||
const data = await response.json().catch(() => ({}))
|
|
||||||
if (!response.ok) {
|
|
||||||
const message = data?.detail?.message || data?.detail || response.statusText || 'API request failed'
|
|
||||||
throw new Error(String(message))
|
|
||||||
}
|
|
||||||
return data as T
|
|
||||||
}
|
|
||||||
|
|
||||||
export const api = {
|
|
||||||
tasks: () => request<{ tasks: any[]; groups: Record<string, any[]> }>('/api/tasks'),
|
|
||||||
task: (id: string) => request<{ task: any }>(`/api/tasks/${id}`),
|
|
||||||
events: (id: string) => request<{ events: any[] }>(`/api/tasks/${id}/events`),
|
|
||||||
runs: (id: string) => request<{ runs: any[] }>(`/api/tasks/${id}/runs`),
|
|
||||||
log: (id: string) => request<{ log: string; missing: boolean }>(`/api/tasks/${id}/log`),
|
|
||||||
create: (payload: Record<string, unknown>) => request<{ task: any }>('/api/tasks', { method: 'POST', body: JSON.stringify(payload) }),
|
|
||||||
comment: (id: string, body: string) => request(`/api/tasks/${id}/comment`, { method: 'POST', body: JSON.stringify({ body }) }),
|
|
||||||
action: (id: string, action: 'promote' | 'unblock' | 'archive') => request(`/api/tasks/${id}/${action}`, { method: 'POST' }),
|
|
||||||
block: (id: string, reason: string) => request(`/api/tasks/${id}/block`, { method: 'POST', body: JSON.stringify({ reason }) }),
|
|
||||||
assign: (id: string, assignee: string) => request(`/api/tasks/${id}/assign`, { method: 'POST', body: JSON.stringify({ assignee }) }),
|
|
||||||
dispatch: () => request('/api/dispatch', { method: 'POST' }),
|
|
||||||
}
|
|
||||||
97
frontend/src/api/client.ts
Normal file
97
frontend/src/api/client.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
import type {
|
||||||
|
Board,
|
||||||
|
BoardListData,
|
||||||
|
TaskSummary,
|
||||||
|
TaskDetail,
|
||||||
|
TaskListData,
|
||||||
|
CreateTaskPayload,
|
||||||
|
CommentPayload,
|
||||||
|
ApiResponse,
|
||||||
|
HealthData,
|
||||||
|
} from '@kanban/contracts'
|
||||||
|
import { getInitData } from '../telegram'
|
||||||
|
|
||||||
|
const BRIDGE_URL = import.meta.env.VITE_BRIDGE_URL || '/bridge'
|
||||||
|
const AUTH_TOKEN: string = import.meta.env.VITE_AUTH_TOKEN || ''
|
||||||
|
|
||||||
|
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||||
|
const headers = new Headers(init.headers)
|
||||||
|
headers.set('Content-Type', 'application/json')
|
||||||
|
|
||||||
|
// Auth: prefer X-Bridge-Token, fall back to Telegram initData
|
||||||
|
if (AUTH_TOKEN) {
|
||||||
|
headers.set('X-Bridge-Token', AUTH_TOKEN)
|
||||||
|
} else {
|
||||||
|
const initData = getInitData()
|
||||||
|
if (initData) {
|
||||||
|
headers.set('X-Bridge-Token', initData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${BRIDGE_URL}${path}`
|
||||||
|
const response = await fetch(url, { ...init, headers })
|
||||||
|
|
||||||
|
const data = await response.json().catch(() => null)
|
||||||
|
|
||||||
|
if (!response.ok || !data) {
|
||||||
|
const message =
|
||||||
|
(data as { error?: { message?: string } })?.error?.message ||
|
||||||
|
response.statusText ||
|
||||||
|
`HTTP ${response.status}`
|
||||||
|
throw new Error(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
const envelope = data as ApiResponse<T>
|
||||||
|
if (!envelope.ok) {
|
||||||
|
throw new Error(envelope.error?.message || 'API error')
|
||||||
|
}
|
||||||
|
|
||||||
|
return envelope.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── API methods ────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function fetchHealth(): Promise<HealthData> {
|
||||||
|
return request<HealthData>('/v1/health')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchBoards(): Promise<Board[]> {
|
||||||
|
return request<BoardListData>('/v1/boards')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTasks(board: string): Promise<TaskListData> {
|
||||||
|
return request<TaskListData>(`/v1/boards/${encodeURIComponent(board)}/tasks`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTask(board: string, taskId: string): Promise<TaskDetail> {
|
||||||
|
return request<TaskDetail>(
|
||||||
|
`/v1/boards/${encodeURIComponent(board)}/tasks/${encodeURIComponent(taskId)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTask(
|
||||||
|
board: string,
|
||||||
|
payload: CreateTaskPayload
|
||||||
|
): Promise<TaskDetail> {
|
||||||
|
return request<TaskDetail>(
|
||||||
|
`/v1/boards/${encodeURIComponent(board)}/tasks`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addComment(
|
||||||
|
board: string,
|
||||||
|
taskId: string,
|
||||||
|
payload: CommentPayload
|
||||||
|
): Promise<void> {
|
||||||
|
await request<void>(
|
||||||
|
`/v1/boards/${encodeURIComponent(board)}/tasks/${encodeURIComponent(taskId)}/comments`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
41
frontend/src/components/BoardSelector.tsx
Normal file
41
frontend/src/components/BoardSelector.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import type { Board } from '@kanban/contracts'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
boards: Board[]
|
||||||
|
onSelect: (board: Board) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BoardSelector({ boards, onSelect }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
<p className="text-gray-400 text-sm mb-4">Choose a board to view:</p>
|
||||||
|
{boards.map((board) => (
|
||||||
|
<button
|
||||||
|
key={board.slug}
|
||||||
|
onClick={() => onSelect(board)}
|
||||||
|
className="w-full text-left p-4 rounded-xl bg-white/5 border border-white/10 hover:bg-white/10 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{board.icon && (
|
||||||
|
<span className="text-2xl">{board.icon}</span>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h2 className="font-semibold text-white truncate">
|
||||||
|
{board.name || board.slug}
|
||||||
|
</h2>
|
||||||
|
{board.description && (
|
||||||
|
<p className="text-sm text-gray-400 truncate mt-1">
|
||||||
|
{board.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-gray-500">→</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{boards.length === 0 && (
|
||||||
|
<p className="text-center text-gray-500 py-8">No boards available</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
127
frontend/src/components/CreateTaskForm.tsx
Normal file
127
frontend/src/components/CreateTaskForm.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import type { CreateTaskPayload } from '@kanban/contracts'
|
||||||
|
import { hapticFeedback } from '../telegram'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onSubmit: (payload: CreateTaskPayload) => void
|
||||||
|
onCancel: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CreateTaskForm({ onSubmit, onCancel }: Props) {
|
||||||
|
const [title, setTitle] = useState('')
|
||||||
|
const [body, setBody] = useState('')
|
||||||
|
const [assignee, setAssignee] = useState('')
|
||||||
|
const [priority, setPriority] = useState(0)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!title.trim()) {
|
||||||
|
setError('Title is required')
|
||||||
|
hapticFeedback('heavy')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hapticFeedback('medium')
|
||||||
|
onSubmit({
|
||||||
|
title: title.trim(),
|
||||||
|
body: body.trim() || undefined,
|
||||||
|
assignee: assignee.trim() || undefined,
|
||||||
|
priority: priority || undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 pb-20">
|
||||||
|
<h2 className="text-lg font-semibold text-white mb-4">Create Task</h2>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 bg-red-900/20 border border-red-500/20 rounded-lg text-red-300 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Title */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-400 mb-1">
|
||||||
|
Title *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="What needs to be done?"
|
||||||
|
className="w-full p-3 bg-white/5 border border-white/10 rounded-lg text-white placeholder-gray-500 outline-none focus:border-blue-500/50 transition-colors"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-400 mb-1">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={body}
|
||||||
|
onChange={(e) => setBody(e.target.value)}
|
||||||
|
placeholder="Add details…"
|
||||||
|
rows={4}
|
||||||
|
className="w-full p-3 bg-white/5 border border-white/10 rounded-lg text-white placeholder-gray-500 outline-none focus:border-blue-500/50 transition-colors resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Assignee */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-400 mb-1">
|
||||||
|
Assignee
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={assignee}
|
||||||
|
onChange={(e) => setAssignee(e.target.value)}
|
||||||
|
placeholder="Username"
|
||||||
|
className="w-full p-3 bg-white/5 border border-white/10 rounded-lg text-white placeholder-gray-500 outline-none focus:border-blue-500/50 transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Priority */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-400 mb-2">
|
||||||
|
Priority
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{[0, 1, 2, 3, 4, 5].map((p) => (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
onClick={() => setPriority(p)}
|
||||||
|
className={`flex-1 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
priority === p
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-white/5 text-gray-400 hover:bg-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p === 0 ? 'None' : p}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-3 mt-6">
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
className="flex-1 py-3 bg-white/5 border border-white/10 rounded-lg text-gray-300 font-medium hover:bg-white/10 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
className="flex-1 py-3 bg-blue-600 rounded-lg text-white font-medium hover:bg-blue-500 transition-colors"
|
||||||
|
>
|
||||||
|
Create Task
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
89
frontend/src/components/KanbanBoard.tsx
Normal file
89
frontend/src/components/KanbanBoard.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
import type { TaskSummary, TaskStatus, ColumnDef } from '@kanban/contracts'
|
||||||
|
import TaskCard from './TaskCard'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
tasks: TaskSummary[]
|
||||||
|
columns: ColumnDef[]
|
||||||
|
onTaskTap: (task: TaskSummary) => void
|
||||||
|
onRefresh: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function KanbanBoard({ tasks, columns, onTaskTap, onRefresh }: Props) {
|
||||||
|
// Group tasks by status
|
||||||
|
const grouped: Record<TaskStatus, TaskSummary[]> = {} as Record<TaskStatus, TaskSummary[]>
|
||||||
|
for (const col of columns) {
|
||||||
|
grouped[col.status] = []
|
||||||
|
}
|
||||||
|
for (const task of tasks) {
|
||||||
|
const status = task.status as TaskStatus
|
||||||
|
if (!grouped[status]) {
|
||||||
|
grouped[status] = []
|
||||||
|
}
|
||||||
|
grouped[status].push(task)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 overflow-x-auto">
|
||||||
|
<div className="flex gap-3 p-4 min-w-max pb-20">
|
||||||
|
{columns.map((col) => (
|
||||||
|
<div
|
||||||
|
key={col.status}
|
||||||
|
className="flex-shrink-0 w-[280px] flex flex-col"
|
||||||
|
>
|
||||||
|
{/* Column header */}
|
||||||
|
<div className="flex items-center justify-between mb-3 px-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<StatusDot status={col.status} />
|
||||||
|
<h3 className="font-medium text-sm text-gray-300 uppercase tracking-wider">
|
||||||
|
{col.title}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-500 bg-white/5 px-2 py-0.5 rounded-full">
|
||||||
|
{grouped[col.status]?.length || 0}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Column content */}
|
||||||
|
<div className="flex-1 space-y-2 min-h-[200px]">
|
||||||
|
{grouped[col.status]?.map((task) => (
|
||||||
|
<TaskCard
|
||||||
|
key={task.id}
|
||||||
|
task={task}
|
||||||
|
onTap={() => onTaskTap(task)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{(!grouped[col.status] || grouped[col.status].length === 0) && (
|
||||||
|
<div className="text-center py-8 text-gray-600 text-sm">
|
||||||
|
No tasks
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pull to refresh hint */}
|
||||||
|
<button
|
||||||
|
onClick={onRefresh}
|
||||||
|
className="fixed bottom-4 right-4 p-3 bg-blue-600 rounded-full shadow-lg z-10"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-white" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fillRule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusDot({ status }: { status: TaskStatus }) {
|
||||||
|
const colors: Record<TaskStatus, string> = {
|
||||||
|
triage: 'bg-gray-400',
|
||||||
|
todo: 'bg-blue-400',
|
||||||
|
ready: 'bg-yellow-400',
|
||||||
|
running: 'bg-green-400',
|
||||||
|
blocked: 'bg-red-400',
|
||||||
|
done: 'bg-emerald-400',
|
||||||
|
archived: 'bg-gray-500',
|
||||||
|
}
|
||||||
|
return <div className={`w-2 h-2 rounded-full ${colors[status] || 'bg-gray-400'}`} />
|
||||||
|
}
|
||||||
68
frontend/src/components/TaskCard.tsx
Normal file
68
frontend/src/components/TaskCard.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
import type { TaskSummary, TaskStatus } from '@kanban/contracts'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
task: TaskSummary
|
||||||
|
onTap: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TaskCard({ task, onTap }: Props) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onTap}
|
||||||
|
className="w-full text-left p-3 rounded-xl bg-white/5 border border-white/10 hover:bg-white/10 transition-colors active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* Title */}
|
||||||
|
<h4 className="font-medium text-white text-sm leading-snug line-clamp-2">
|
||||||
|
{task.title}
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
{/* Meta row */}
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<StatusBadge status={task.status as TaskStatus} />
|
||||||
|
{task.assignee && (
|
||||||
|
<span className="text-xs text-gray-400 truncate">
|
||||||
|
@{task.assignee}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Priority */}
|
||||||
|
{task.priority != null && task.priority > 0 && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{Array.from({ length: Math.min(task.priority, 5) }).map((_, i) => (
|
||||||
|
<span key={i} className="text-orange-400 text-xs">▲</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Blocked reason */}
|
||||||
|
{task.blockedReason && (
|
||||||
|
<p className="text-xs text-red-400 line-clamp-1">
|
||||||
|
⚠ {task.blockedReason}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: TaskStatus }) {
|
||||||
|
const config: Record<TaskStatus, { bg: string; text: string; label: string }> = {
|
||||||
|
triage: { bg: 'bg-gray-500/20', text: 'text-gray-300', label: 'Triage' },
|
||||||
|
todo: { bg: 'bg-blue-500/20', text: 'text-blue-300', label: 'To Do' },
|
||||||
|
ready: { bg: 'bg-yellow-500/20', text: 'text-yellow-300', label: 'Ready' },
|
||||||
|
running: { bg: 'bg-green-500/20', text: 'text-green-300', label: 'Running' },
|
||||||
|
blocked: { bg: 'bg-red-500/20', text: 'text-red-300', label: 'Blocked' },
|
||||||
|
done: { bg: 'bg-emerald-500/20', text: 'text-emerald-300', label: 'Done' },
|
||||||
|
archived: { bg: 'bg-gray-600/20', text: 'text-gray-400', label: 'Archived' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const { bg, text, label } = config[status] || config.todo
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${bg} ${text}`}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
212
frontend/src/components/TaskDetailModal.tsx
Normal file
212
frontend/src/components/TaskDetailModal.tsx
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import type { TaskDetail, TaskComment, CommentPayload } from '@kanban/contracts'
|
||||||
|
import { hapticFeedback } from '../telegram'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
task: TaskDetail
|
||||||
|
onClose: () => void
|
||||||
|
onAddComment: (payload: CommentPayload) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TaskDetailModal({ task, onClose, onAddComment }: Props) {
|
||||||
|
const [commentText, setCommentText] = useState('')
|
||||||
|
const [showCommentForm, setShowCommentForm] = useState(false)
|
||||||
|
|
||||||
|
const handleSubmitComment = () => {
|
||||||
|
if (!commentText.trim()) return
|
||||||
|
hapticFeedback('medium')
|
||||||
|
onAddComment({ body: commentText.trim() })
|
||||||
|
setCommentText('')
|
||||||
|
setShowCommentForm(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 overflow-y-auto pb-20">
|
||||||
|
{/* Task header */}
|
||||||
|
<div className="p-4 border-b border-white/10">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h2 className="text-lg font-semibold text-white leading-snug">
|
||||||
|
{task.title}
|
||||||
|
</h2>
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<StatusBadge status={task.status} />
|
||||||
|
{task.assignee && (
|
||||||
|
<span className="text-sm text-gray-400">@{task.assignee}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Priority */}
|
||||||
|
{task.priority != null && task.priority > 0 && (
|
||||||
|
<div className="flex items-center gap-1 mt-3">
|
||||||
|
<span className="text-xs text-gray-500">Priority:</span>
|
||||||
|
{Array.from({ length: Math.min(task.priority, 5) }).map((_, i) => (
|
||||||
|
<span key={i} className="text-orange-400">▲</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Blocked reason */}
|
||||||
|
{task.blockedReason && (
|
||||||
|
<div className="mt-3 p-3 bg-red-900/20 border border-red-500/20 rounded-lg">
|
||||||
|
<p className="text-sm text-red-300">
|
||||||
|
<span className="font-medium">Blocked:</span> {task.blockedReason}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{task.body && (
|
||||||
|
<div className="p-4 border-b border-white/10">
|
||||||
|
<h3 className="text-sm font-medium text-gray-400 mb-2">Description</h3>
|
||||||
|
<p className="text-sm text-gray-300 leading-relaxed whitespace-pre-wrap">
|
||||||
|
{task.body}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Comments section */}
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="text-sm font-medium text-gray-400">
|
||||||
|
Comments ({task.comments?.length || 0})
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCommentForm(!showCommentForm)}
|
||||||
|
className="text-sm text-blue-400 hover:text-blue-300"
|
||||||
|
>
|
||||||
|
{showCommentForm ? 'Cancel' : '+ Add'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comment form */}
|
||||||
|
{showCommentForm && (
|
||||||
|
<div className="mb-4 p-3 bg-white/5 rounded-lg border border-white/10">
|
||||||
|
<textarea
|
||||||
|
value={commentText}
|
||||||
|
onChange={(e) => setCommentText(e.target.value)}
|
||||||
|
placeholder="Write a comment…"
|
||||||
|
className="w-full bg-transparent border-0 outline-none resize-none text-sm text-white placeholder-gray-500 min-h-[80px]"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-2 mt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCommentForm(false)}
|
||||||
|
className="px-3 py-1.5 text-sm text-gray-400 hover:text-white"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmitComment}
|
||||||
|
disabled={!commentText.trim()}
|
||||||
|
className="px-3 py-1.5 text-sm bg-blue-600 rounded-lg text-white disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Comments list */}
|
||||||
|
{task.comments && task.comments.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{task.comments.map((comment, i) => (
|
||||||
|
<CommentItem key={comment.id || i} comment={comment} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-500 text-center py-4">
|
||||||
|
No comments yet
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Events */}
|
||||||
|
{task.events && task.events.length > 0 && (
|
||||||
|
<div className="p-4 border-t border-white/10">
|
||||||
|
<h3 className="text-sm font-medium text-gray-400 mb-3">Activity</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{task.events.map((event, i) => (
|
||||||
|
<div key={event.id || i} className="flex items-start gap-2 text-sm">
|
||||||
|
<span className="text-gray-600 mt-0.5">•</span>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-300">{event.type}</span>
|
||||||
|
{event.message && (
|
||||||
|
<span className="text-gray-500 ml-1">— {event.message}</span>
|
||||||
|
)}
|
||||||
|
{event.createdAt && (
|
||||||
|
<span className="text-gray-600 text-xs ml-2">
|
||||||
|
{new Date(event.createdAt).toLocaleTimeString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Runs */}
|
||||||
|
{task.runs && task.runs.length > 0 && (
|
||||||
|
<div className="p-4 border-t border-white/10">
|
||||||
|
<h3 className="text-sm font-medium text-gray-400 mb-3">Runs</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{task.runs.map((run, i) => (
|
||||||
|
<div key={run.id || i} className="p-2 bg-white/5 rounded-lg text-sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-gray-300">{run.status || 'unknown'}</span>
|
||||||
|
{run.startedAt && (
|
||||||
|
<span className="text-gray-500 text-xs">
|
||||||
|
{new Date(run.startedAt).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommentItem({ comment }: { comment: TaskComment }) {
|
||||||
|
return (
|
||||||
|
<div className="p-3 bg-white/5 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-sm font-medium text-white">
|
||||||
|
{comment.author || 'Anonymous'}
|
||||||
|
</span>
|
||||||
|
{comment.createdAt && (
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{new Date(comment.createdAt).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-300 leading-relaxed">{comment.body}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: string }) {
|
||||||
|
const config: Record<string, { bg: string; text: string; label: string }> = {
|
||||||
|
triage: { bg: 'bg-gray-500/20', text: 'text-gray-300', label: 'Triage' },
|
||||||
|
todo: { bg: 'bg-blue-500/20', text: 'text-blue-300', label: 'To Do' },
|
||||||
|
ready: { bg: 'bg-yellow-500/20', text: 'text-yellow-300', label: 'Ready' },
|
||||||
|
running: { bg: 'bg-green-500/20', text: 'text-green-300', label: 'Running' },
|
||||||
|
blocked: { bg: 'bg-red-500/20', text: 'text-red-300', label: 'Blocked' },
|
||||||
|
done: { bg: 'bg-emerald-500/20', text: 'text-emerald-300', label: 'Done' },
|
||||||
|
archived: { bg: 'bg-gray-600/20', text: 'text-gray-400', label: 'Archived' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const { bg, text, label } = config[status] || config.todo
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${bg} ${text}`}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
45
frontend/src/index.css
Normal file
45
frontend/src/index.css
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--tg-bg-color: #0f1420;
|
||||||
|
--tg-text-color: #f5f7fb;
|
||||||
|
--tg-hint-color: #9aa4b2;
|
||||||
|
--tg-link-color: #3b82f6;
|
||||||
|
--tg-button-color: #3b82f6;
|
||||||
|
--tg-button-text-color: #ffffff;
|
||||||
|
--tg-secondary-bg-color: #1a1f2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
background-color: var(--tg-bg-color);
|
||||||
|
color: var(--tg-text-color);
|
||||||
|
min-height: 100vh;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
height: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
import './styles.css'
|
import './index.css'
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</React.StrictMode>,
|
</React.StrictMode>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
import '@testing-library/jest-dom/vitest'
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
:root {
|
|
||||||
color-scheme: light dark;
|
|
||||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
||||||
background: var(--tg-bg-color, #10131a);
|
|
||||||
color: var(--tg-text-color, #f5f7fb);
|
|
||||||
}
|
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
body { margin: 0; min-width: 320px; }
|
|
||||||
button, input, textarea, select { font: inherit; }
|
|
||||||
button { border: 0; border-radius: 12px; padding: .7rem .9rem; cursor: pointer; background: #242b38; color: inherit; display: inline-flex; align-items: center; gap: .4rem; }
|
|
||||||
button:hover { filter: brightness(1.12); }
|
|
||||||
input, textarea, select { width: 100%; border-radius: 12px; border: 1px solid #374151; padding: .75rem; background: #151a23; color: inherit; }
|
|
||||||
textarea { min-height: 110px; resize: vertical; }
|
|
||||||
pre { white-space: pre-wrap; word-break: break-word; background: #0b0e14; border: 1px solid #242b38; border-radius: 12px; padding: .8rem; max-height: 240px; overflow: auto; }
|
|
||||||
|
|
||||||
.app-shell { padding: 1rem; max-width: 1500px; margin: 0 auto; }
|
|
||||||
.topbar { display: flex; justify-content: space-between; gap: 1rem; align-items: center; margin-bottom: 1rem; flex-wrap: wrap; }
|
|
||||||
h1 { margin: 0; font-size: clamp(1.5rem, 5vw, 2.4rem); }
|
|
||||||
h2, h3 { margin: .3rem 0 .8rem; }
|
|
||||||
.eyebrow { color: var(--tg-hint-color, #9aa4b2); margin: 0; text-transform: uppercase; letter-spacing: .12em; font-size: .75rem; }
|
|
||||||
.row { display: flex; gap: .6rem; align-items: center; flex-wrap: wrap; }
|
|
||||||
.primary { background: var(--tg-button-color, #3b82f6); color: var(--tg-button-text-color, white); }
|
|
||||||
.ghost { background: transparent; border: 1px solid #374151; }
|
|
||||||
.error { background: #431d23; color: #fecdd3; border: 1px solid #f43f5e; border-radius: 14px; padding: .8rem 1rem; margin: .8rem 0; }
|
|
||||||
|
|
||||||
.board { display: grid; grid-template-columns: repeat(7, minmax(220px, 1fr)); gap: .9rem; overflow-x: auto; padding-bottom: 1rem; }
|
|
||||||
.column { background: rgba(255,255,255,.045); border: 1px solid rgba(255,255,255,.08); border-radius: 18px; min-height: 50vh; padding: .9rem; }
|
|
||||||
.column h2 { text-transform: lowercase; font-size: 1rem; color: var(--tg-accent-text-color, #93c5fd); }
|
|
||||||
.task-card { width: 100%; display: grid; grid-template-columns: 1fr auto; text-align: left; gap: .35rem .5rem; margin-bottom: .75rem; border: 1px solid rgba(255,255,255,.08); background: #171c26; }
|
|
||||||
.task-card strong { grid-column: 1 / -1; }
|
|
||||||
.task-id, .empty { color: var(--tg-hint-color, #9aa4b2); font-size: .82rem; }
|
|
||||||
.status-pill { border-radius: 999px; background: #1d4ed8; padding: .1rem .5rem; font-size: .78rem; justify-self: end; }
|
|
||||||
.modal-card { position: fixed; z-index: 4; right: 1rem; top: 5rem; width: min(440px, calc(100vw - 2rem)); display: grid; gap: .6rem; padding: 1rem; border-radius: 20px; background: #111827; border: 1px solid #374151; box-shadow: 0 20px 80px rgba(0,0,0,.45); }
|
|
||||||
.drawer { position: fixed; inset: 0 0 0 auto; width: min(560px, 100vw); background: #0f1420; border-left: 1px solid #374151; padding: 1rem; overflow-y: auto; z-index: 3; box-shadow: -20px 0 80px rgba(0,0,0,.35); }
|
|
||||||
.close { float: right; border-radius: 50%; width: 40px; height: 40px; justify-content: center; }
|
|
||||||
.body { color: #d1d5db; line-height: 1.55; }
|
|
||||||
.actions { display: flex; flex-wrap: wrap; gap: .5rem; margin: 1rem 0; }
|
|
||||||
|
|
||||||
@media (max-width: 800px) {
|
|
||||||
.app-shell { padding: .75rem; }
|
|
||||||
.board { grid-template-columns: repeat(7, 82vw); }
|
|
||||||
.topbar { align-items: stretch; }
|
|
||||||
.topbar .row, .topbar button { width: 100%; justify-content: center; }
|
|
||||||
}
|
|
||||||
|
|
@ -1,31 +1,103 @@
|
||||||
|
// Telegram WebApp integration
|
||||||
|
// Uses window.Telegram.WebApp directly for maximum compatibility
|
||||||
|
// @telegram-apps/sdk-react provides typed wrappers but we keep a thin layer here
|
||||||
|
|
||||||
|
export function initTelegram(): void {
|
||||||
|
const tg = window.Telegram?.WebApp
|
||||||
|
if (tg) {
|
||||||
|
tg.ready()
|
||||||
|
tg.expand()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInitData(): string {
|
||||||
|
return window.Telegram?.WebApp?.initData || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getColorScheme(): 'light' | 'dark' {
|
||||||
|
return window.Telegram?.WebApp?.colorScheme || 'dark'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getThemeParams(): Record<string, string> {
|
||||||
|
return window.Telegram?.WebApp?.themeParams || {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hapticFeedback(type: 'light' | 'medium' | 'heavy' = 'medium'): void {
|
||||||
|
window.Telegram?.WebApp?.HapticFeedback?.impactOccurred(type)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showMainButton(text: string, onClick: () => void): void {
|
||||||
|
const btn = window.Telegram?.WebApp?.MainButton
|
||||||
|
if (btn) {
|
||||||
|
btn.setText(text)
|
||||||
|
btn.show()
|
||||||
|
btn.onClick(onClick)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hideMainButton(): void {
|
||||||
|
window.Telegram?.WebApp?.MainButton?.hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showBackButton(onClick: () => void): void {
|
||||||
|
const btn = window.Telegram?.WebApp?.BackButton
|
||||||
|
if (btn) {
|
||||||
|
btn.show()
|
||||||
|
btn.onClick(onClick)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hideBackButton(): void {
|
||||||
|
window.Telegram?.WebApp?.BackButton?.hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeMiniApp(): void {
|
||||||
|
window.Telegram?.WebApp?.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply Telegram theme CSS variables
|
||||||
|
export function applyTheme(): void {
|
||||||
|
const params = getThemeParams()
|
||||||
|
const root = document.documentElement
|
||||||
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
root.style.setProperty(`--tg-${key.replaceAll('_', '-')}`, value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const scheme = getColorScheme()
|
||||||
|
root.dataset.theme = scheme
|
||||||
|
}
|
||||||
|
|
||||||
|
// Augment Window for TypeScript
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
Telegram?: {
|
Telegram?: {
|
||||||
WebApp?: {
|
WebApp?: {
|
||||||
ready?: () => void
|
ready: () => void
|
||||||
expand?: () => void
|
expand: () => void
|
||||||
initData?: string
|
close: () => void
|
||||||
colorScheme?: 'light' | 'dark'
|
initData: string
|
||||||
themeParams?: Record<string, string>
|
colorScheme: 'light' | 'dark'
|
||||||
|
themeParams: Record<string, string>
|
||||||
|
HapticFeedback?: {
|
||||||
|
impactOccurred: (type: 'light' | 'medium' | 'heavy') => void
|
||||||
|
notificationOccurred: (type: 'error' | 'success' | 'warning') => void
|
||||||
|
selectionChanged: () => void
|
||||||
|
}
|
||||||
|
MainButton?: {
|
||||||
|
setText: (text: string) => void
|
||||||
|
show: () => void
|
||||||
|
hide: () => void
|
||||||
|
onClick: (cb: () => void) => void
|
||||||
|
offClick: (cb: () => void) => void
|
||||||
|
}
|
||||||
|
BackButton?: {
|
||||||
|
show: () => void
|
||||||
|
hide: () => void
|
||||||
|
onClick: (cb: () => void) => void
|
||||||
|
offClick: (cb: () => void) => void
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTelegramInitData(): string | undefined {
|
|
||||||
return window.Telegram?.WebApp?.initData || undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyTelegramTheme(): void {
|
|
||||||
const webApp = window.Telegram?.WebApp
|
|
||||||
webApp?.ready?.()
|
|
||||||
webApp?.expand?.()
|
|
||||||
const theme = webApp?.themeParams || {}
|
|
||||||
const root = document.documentElement
|
|
||||||
Object.entries(theme).forEach(([key, value]) => {
|
|
||||||
if (typeof value === 'string') root.style.setProperty(`--tg-${key.replaceAll('_', '-')}`, value)
|
|
||||||
})
|
|
||||||
if (webApp?.colorScheme) root.dataset.theme = webApp.colorScheme
|
|
||||||
}
|
|
||||||
|
|
||||||
export {}
|
|
||||||
|
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
export type KanbanStatus = 'triage' | 'todo' | 'ready' | 'running' | 'blocked' | 'done' | 'archived'
|
|
||||||
|
|
||||||
export type Task = {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
status: KanbanStatus | string
|
|
||||||
assignee?: string | null
|
|
||||||
priority?: number | null
|
|
||||||
age?: string | null
|
|
||||||
body?: string | null
|
|
||||||
created_at?: number | null
|
|
||||||
started_at?: number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TaskGroups = Record<string, Task[]>
|
|
||||||
|
|
||||||
export type Event = Record<string, unknown> & { kind?: string }
|
|
||||||
export type Run = Record<string, unknown> & { id?: number | string; status?: string }
|
|
||||||
9
frontend/src/vite-env.d.ts
vendored
9
frontend/src/vite-env.d.ts
vendored
|
|
@ -1,3 +1,10 @@
|
||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
declare module '*.css'
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_BRIDGE_URL: string
|
||||||
|
readonly VITE_AUTH_TOKEN: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv
|
||||||
|
}
|
||||||
|
|
|
||||||
8
frontend/tailwind.config.js
Normal file
8
frontend/tailwind.config.js
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
|
|
@ -14,8 +14,7 @@
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx"
|
||||||
"types": ["vitest/globals", "@testing-library/jest-dom"]
|
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,12 @@ import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
test: {
|
|
||||||
setupFiles: './src/setupTests.ts',
|
|
||||||
environment: 'jsdom',
|
|
||||||
},
|
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': 'http://127.0.0.1:8000',
|
'/bridge': {
|
||||||
|
target: 'http://127.0.0.1:3456',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
49
packages/bridge/Dockerfile
Normal file
49
packages/bridge/Dockerfile
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# @kanban/bridge — Multi-stage Docker build
|
||||||
|
# Stage 1: install deps + build TypeScript
|
||||||
|
# Stage 2: production runtime with node
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# ── Stage 1: Build ─────────────────────────────────────────
|
||||||
|
FROM node:22-alpine AS build
|
||||||
|
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy workspace scaffolding
|
||||||
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||||
|
COPY packages/contracts/package.json packages/contracts/
|
||||||
|
COPY packages/bridge/package.json packages/bridge/
|
||||||
|
|
||||||
|
# Install all dependencies (including dev for build)
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY packages/contracts/ packages/contracts/
|
||||||
|
COPY packages/bridge/ packages/bridge/
|
||||||
|
|
||||||
|
# Build contracts first (bridge depends on it), then bridge
|
||||||
|
RUN pnpm --filter @kanban/contracts build && \
|
||||||
|
pnpm --filter @kanban/bridge build
|
||||||
|
|
||||||
|
# ── Stage 2: Runtime ───────────────────────────────────────
|
||||||
|
FROM node:22-alpine AS runtime
|
||||||
|
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy workspace scaffolding
|
||||||
|
COPY --from=build /app/package.json ./
|
||||||
|
COPY --from=build /app/pnpm-lock.yaml ./
|
||||||
|
COPY --from=build /app/pnpm-workspace.yaml ./
|
||||||
|
|
||||||
|
# Copy built artifacts + package manifests
|
||||||
|
COPY --from=build /app/packages/contracts/package.json packages/contracts/
|
||||||
|
COPY --from=build /app/packages/contracts/dist packages/contracts/dist/
|
||||||
|
COPY --from=build /app/packages/bridge/package.json packages/bridge/
|
||||||
|
COPY --from=build /app/packages/bridge/dist packages/bridge/dist/
|
||||||
|
|
||||||
|
# Install production dependencies only
|
||||||
|
RUN pnpm install --frozen-lockfile --prod
|
||||||
|
|
||||||
|
EXPOSE 3456
|
||||||
|
WORKDIR /app/packages/bridge
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
|
|
@ -91,9 +91,9 @@ export class HermesKanbanClient {
|
||||||
const fullArgs = [
|
const fullArgs = [
|
||||||
'kanban',
|
'kanban',
|
||||||
subcommand,
|
subcommand,
|
||||||
|
...args,
|
||||||
'--board', board ?? this.config.defaultBoard,
|
'--board', board ?? this.config.defaultBoard,
|
||||||
'--json',
|
'--json',
|
||||||
...args,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const env = {
|
const env = {
|
||||||
|
|
@ -126,7 +126,7 @@ export class HermesKanbanClient {
|
||||||
// ─── Convenience methods (M2 will flesh these out) ────────
|
// ─── Convenience methods (M2 will flesh these out) ────────
|
||||||
|
|
||||||
async listBoards(): Promise<Board[]> {
|
async listBoards(): Promise<Board[]> {
|
||||||
return this.exec<Board[]>('boards', []);
|
return this.exec<Board[]>('boards', ['list']);
|
||||||
}
|
}
|
||||||
|
|
||||||
async listTasks(
|
async listTasks(
|
||||||
|
|
|
||||||
1464
pnpm-lock.yaml
generated
1464
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,3 +1,3 @@
|
||||||
packages:
|
packages:
|
||||||
- 'packages/*'
|
- 'packages/*'
|
||||||
# Future: - 'apps/*' for BFF, frontend
|
- 'frontend'
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue