Kaynağa Gözat

Add dashboard for person management

- server.py: wire up Jinja2Templates, StaticFiles, mount dashboard router
- dashboard.py: full CRUD routes (list, detail, add form, edit form, delete)
- templates: base.html (navbar), dashboard.html, persons.html (table),
  person-detail.html, person-form.html (shared add/edit)
- static/style.css: dark theme with navbar, cards, tables, badges, forms,
  buttons, detail grid, responsive layout
- static/app.js: confirm-delete handler, auto-focus first input
- All 102 tests pass, all CRUD smoke tests pass
Lukas Goldschmidt 1 ay önce
ebeveyn
işleme
a1157be001

+ 178 - 0
src/astro_mcp/dashboard.py

@@ -0,0 +1,178 @@
+"""
+Dashboard HTTP routes for astro-mcp.
+
+Serves Jinja2 templates for person management.
+All routes are prefixed with /dashboard.
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from fastapi import APIRouter, Form, Request
+from fastapi.responses import HTMLResponse, RedirectResponse
+
+from . import storage
+from .server import templates
+
+logger = logging.getLogger("astro-mcp.dashboard")
+
+router = APIRouter()
+
+
+# ── helpers ──────────────────────────────────────────────────────────
+
+async def _get_all_persons() -> list[dict[str, Any]]:
+    return await storage.list_persons()
+
+
+async def _get_one_person(person_id: str) -> dict[str, Any] | None:
+    person = await storage.get_person(person_id=person_id)
+    if not person:
+        person = await storage.get_person(nickname=person_id)
+    return person
+
+
+# ── dashboard home ───────────────────────────────────────────────────
+
+@router.get("/dashboard", response_class=HTMLResponse)
+async def dashboard_home(request: Request):
+    return templates.TemplateResponse(
+        request, "dashboard.html", {}
+    )
+
+
+# ── person list ──────────────────────────────────────────────────────
+
+@router.get("/dashboard/persons", response_class=HTMLResponse)
+async def person_list(request: Request):
+    persons = await _get_all_persons()
+    return templates.TemplateResponse(
+        request, "persons.html",
+        {"persons": persons},
+    )
+
+
+# ── person detail ────────────────────────────────────────────────────
+
+@router.get("/dashboard/persons/{person_id}", response_class=HTMLResponse)
+async def person_detail(request: Request, person_id: str):
+    person = await _get_one_person(person_id)
+    if not person:
+        return RedirectResponse("/dashboard/persons", status_code=303)
+    return templates.TemplateResponse(
+        request, "person-detail.html",
+        {"person": person},
+    )
+
+
+# ── add person (form) ────────────────────────────────────────────────
+
+@router.get("/dashboard/persons/new", response_class=HTMLResponse)
+async def person_add_form(request: Request):
+    return templates.TemplateResponse(
+        request, "person-form.html",
+        {"person": None, "action": "add"},
+    )
+
+
+# ── add person (submit) ──────────────────────────────────────────────
+
+@router.post("/dashboard/persons", response_class=HTMLResponse)
+async def person_add_submit(
+    request: Request,
+    name: str = Form(...),
+    nickname: str = Form(default=""),
+    birth_datetime: str = Form(...),
+    birthplace: str = Form(default=""),
+    latitude: float = Form(...),
+    longitude: float = Form(...),
+    elevation: float = Form(default=0.0),
+    alive: bool = Form(default=True),
+    private: bool = Form(default=False),
+    gender: str = Form(default=""),
+    description: str = Form(default=""),
+    notes: str = Form(default=""),
+    tz: str = Form(default=""),
+    birth_time_known: bool = Form(default=True),
+):
+    await storage.add_person(
+        name=name,
+        birth_datetime=birth_datetime,
+        latitude=latitude,
+        longitude=longitude,
+        elevation=elevation,
+        nickname=nickname or None,
+        birthplace=birthplace or None,
+        alive=alive,
+        private=private,
+        gender=gender or None,
+        description=description or None,
+        notes=notes or None,
+        tz=tz or None,
+        birth_time_known=birth_time_known,
+    )
+    return RedirectResponse("/dashboard/persons", status_code=303)
+
+
+# ── edit person (form) ───────────────────────────────────────────────
+
+@router.get("/dashboard/persons/{person_id}/edit", response_class=HTMLResponse)
+async def person_edit_form(request: Request, person_id: str):
+    person = await _get_one_person(person_id)
+    if not person:
+        return RedirectResponse("/dashboard/persons", status_code=303)
+    return templates.TemplateResponse(
+        request, "person-form.html",
+        {"person": person, "action": "edit"},
+    )
+
+
+# ── edit person (submit) ─────────────────────────────────────────────
+
+@router.post("/dashboard/persons/{person_id}", response_class=HTMLResponse)
+async def person_edit_submit(
+    request: Request,
+    person_id: str,
+    name: str = Form(...),
+    nickname: str = Form(default=""),
+    birth_datetime: str = Form(...),
+    birthplace: str = Form(default=""),
+    latitude: float = Form(...),
+    longitude: float = Form(...),
+    elevation: float = Form(default=0.0),
+    alive: bool = Form(default=True),
+    private: bool = Form(default=False),
+    gender: str = Form(default=""),
+    description: str = Form(default=""),
+    notes: str = Form(default=""),
+    tz: str = Form(default=""),
+    birth_time_known: bool = Form(default=True),
+):
+    await storage.update_person(
+        person_id=person_id,
+        name=name,
+        birth_datetime=birth_datetime,
+        latitude=latitude,
+        longitude=longitude,
+        elevation=elevation,
+        nickname=nickname or None,
+        birthplace=birthplace or None,
+        alive=alive,
+        private=private,
+        gender=gender or None,
+        description=description or None,
+        notes=notes or None,
+        tz=tz or None,
+        birth_time_known=birth_time_known,
+    )
+    return RedirectResponse(f"/dashboard/persons/{person_id}", status_code=303)
+
+
+# ── delete person ────────────────────────────────────────────────────
+
+@router.post("/dashboard/persons/{person_id}/delete", response_class=HTMLResponse)
+async def person_delete(person_id: str):
+    await storage.delete_person(person_id)
+    return RedirectResponse("/dashboard/persons", status_code=303)

+ 19 - 0
src/astro_mcp/server.py

@@ -1,8 +1,11 @@
 from __future__ import annotations
 
 import logging
+from pathlib import Path
 
 from fastapi import FastAPI
+from fastapi.staticfiles import StaticFiles
+from fastapi.templating import Jinja2Templates
 from mcp.server.fastmcp import FastMCP
 from mcp.server.transport_security import TransportSecuritySettings
 
@@ -20,6 +23,12 @@ mcp = FastMCP(
 # Import tools module to register all @mcp.tool() handlers
 from . import tools  # noqa: E402, F401
 
+# Templates and static files
+TEMPLATES_DIR = Path(__file__).resolve().parent.parent.parent / "templates"
+STATIC_DIR = Path(__file__).resolve().parent.parent.parent / "static"
+
+templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
+
 
 def _tool_names() -> list[str]:
     return [
@@ -48,8 +57,18 @@ def create_app() -> FastAPI:
     )
 
     app = FastAPI(title="astro-mcp")
+
+    # Mount static files
+    if STATIC_DIR.exists():
+        app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
+
+    # Mount MCP SSE
     app.mount("/mcp", mcp.sse_app())
 
+    # Dashboard routes
+    from .dashboard import router as dashboard_router  # noqa: E402
+    app.include_router(dashboard_router)
+
     @app.get("/health")
     def health() -> dict:
         return {"ok": True, "server": "astro-mcp", "port": config.PORT}

+ 16 - 0
static/app.js

@@ -0,0 +1,16 @@
+/* ── dashboard app JS ──────────────────────────────────────────────── */
+
+document.addEventListener('DOMContentLoaded', () => {
+  // Confirm delete buttons
+  document.querySelectorAll('[data-confirm]').forEach(el => {
+    el.addEventListener('click', (e) => {
+      if (!confirm(el.dataset.confirm || 'Are you sure?')) {
+        e.preventDefault();
+      }
+    });
+  });
+
+  // Auto-focus first input on forms
+  const firstInput = document.querySelector('.form-group input:not([type=hidden])');
+  if (firstInput) firstInput.focus();
+});

+ 161 - 0
static/style.css

@@ -0,0 +1,161 @@
+/* ── reset & base ─────────────────────────────────────────────────── */
+* { box-sizing: border-box; margin: 0; padding: 0; }
+body {
+  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+  background: #0f1117;
+  color: #c9d1d9;
+  line-height: 1.6;
+}
+
+/* ── navbar ────────────────────────────────────────────────────────── */
+.navbar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 0.75rem 1.5rem;
+  background: #161b22;
+  border-bottom: 1px solid #30363d;
+}
+.nav-brand {
+  color: #58a6ff;
+  font-weight: 700;
+  font-size: 1.1rem;
+  text-decoration: none;
+}
+.nav-brand:hover { text-decoration: none; }
+.nav-links { display: flex; gap: 1rem; }
+.nav-link {
+  color: #8b949e;
+  text-decoration: none;
+  font-size: 0.9rem;
+}
+.nav-link:hover { color: #c9d1d9; text-decoration: none; }
+
+/* ── layout ────────────────────────────────────────────────────────── */
+.container { max-width: 1100px; margin: 0 auto; padding: 2rem; }
+
+/* ── headings ──────────────────────────────────────────────────────── */
+h1 { color: #58a6ff; margin-bottom: 1rem; font-size: 1.5rem; }
+h2 { color: #79c0ff; margin: 1.5rem 0 0.75rem; font-size: 1.2rem; }
+h3 { color: #79c0ff; margin: 1rem 0 0.5rem; font-size: 1rem; }
+
+/* ── links ─────────────────────────────────────────────────────────── */
+a { color: #58a6ff; text-decoration: none; }
+a:hover { text-decoration: underline; }
+
+/* ── cards ─────────────────────────────────────────────────────────── */
+.card {
+  background: #161b22;
+  border: 1px solid #30363d;
+  border-radius: 6px;
+  padding: 1.5rem;
+  margin: 1rem 0;
+}
+
+/* ── tables ────────────────────────────────────────────────────────── */
+table { width: 100%; border-collapse: collapse; margin: 1rem 0; }
+th, td {
+  padding: 0.6rem 0.75rem;
+  text-align: left;
+  border-bottom: 1px solid #21262d;
+  font-size: 0.875rem;
+}
+th { color: #8b949e; font-weight: 600; font-size: 0.8rem; text-transform: uppercase; }
+tr:hover td { background: #1c2128; }
+
+/* ── badges ────────────────────────────────────────────────────────── */
+.badge {
+  display: inline-block;
+  padding: 0.15rem 0.5rem;
+  border-radius: 12px;
+  font-size: 0.75rem;
+  font-weight: 600;
+}
+.badge-green { background: #238636; color: #fff; }
+.badge-red { background: #da3633; color: #fff; }
+.badge-gray { background: #30363d; color: #8b949e; }
+.badge-blue { background: #1f6feb; color: #fff; }
+
+/* ── buttons ───────────────────────────────────────────────────────── */
+.btn {
+  display: inline-block;
+  padding: 0.4rem 0.8rem;
+  border-radius: 6px;
+  font-size: 0.85rem;
+  font-weight: 500;
+  text-decoration: none;
+  cursor: pointer;
+  border: 1px solid transparent;
+  line-height: 1.4;
+}
+.btn:hover { text-decoration: none; }
+.btn-primary { background: #238636; color: #fff; border-color: #2ea043; }
+.btn-primary:hover { background: #2ea043; }
+.btn-danger { background: #da3633; color: #fff; border-color: #f85149; }
+.btn-danger:hover { background: #f85149; }
+.btn-ghost { background: transparent; color: #58a6ff; border-color: #30363d; }
+.btn-ghost:hover { background: #1c2128; }
+.btn-sm { padding: 0.25rem 0.55rem; font-size: 0.8rem; }
+
+/* ── forms ─────────────────────────────────────────────────────────── */
+.form-group { margin-bottom: 1rem; }
+.form-group label {
+  display: block;
+  margin-bottom: 0.3rem;
+  color: #8b949e;
+  font-size: 0.85rem;
+}
+.form-group input,
+.form-group select,
+.form-group textarea {
+  width: 100%;
+  padding: 0.5rem 0.75rem;
+  background: #0d1117;
+  border: 1px solid #30363d;
+  border-radius: 6px;
+  color: #c9d1d9;
+  font-size: 0.9rem;
+  font-family: inherit;
+}
+.form-group input:focus,
+.form-group select:focus,
+.form-group textarea:focus {
+  outline: none;
+  border-color: #58a6ff;
+  box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.15);
+}
+.form-group textarea { resize: vertical; min-height: 80px; }
+.form-row { display: flex; gap: 1rem; }
+.form-row .form-group { flex: 1; }
+.form-actions { margin-top: 1.5rem; display: flex; gap: 0.75rem; }
+.form-hint { color: #6e7681; font-size: 0.8rem; margin-top: 0.2rem; }
+
+/* ── detail grid ───────────────────────────────────────────────────── */
+.detail-grid {
+  display: grid;
+  grid-template-columns: 180px 1fr;
+  gap: 0.5rem 1rem;
+  margin: 1rem 0;
+}
+.detail-label { color: #8b949e; font-size: 0.85rem; }
+.detail-value { font-size: 0.9rem; }
+
+/* ── toolbar ───────────────────────────────────────────────────────── */
+.toolbar { display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem; }
+.toolbar .search-input { max-width: 300px; }
+
+/* ── status ────────────────────────────────────────────────────────── */
+.status-ok { color: #3fb950; }
+.status-warn { color: #d29922; }
+
+/* ── empty state ───────────────────────────────────────────────────── */
+.empty { color: #6e7681; text-align: center; padding: 3rem; font-size: 0.95rem; }
+
+/* ── responsive ────────────────────────────────────────────────────── */
+@media (max-width: 700px) {
+  .container { padding: 1rem; }
+  .form-row { flex-direction: column; gap: 0; }
+  .detail-grid { grid-template-columns: 1fr; }
+  .toolbar { flex-direction: column; align-items: stretch; }
+  .toolbar .search-input { max-width: none; }
+}

+ 9 - 11
templates/base.html

@@ -4,21 +4,19 @@
   <meta charset="UTF-8">
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <title>{% block title %}astro-mcp{% endblock %}</title>
-  <style>
-    * { box-sizing: border-box; margin: 0; padding: 0; }
-    body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f1117; color: #c9d1d9; line-height: 1.6; }
-    .container { max-width: 960px; margin: 0 auto; padding: 2rem; }
-    h1 { color: #58a6ff; margin-bottom: 1rem; }
-    h2 { color: #79c0ff; margin: 1.5rem 0 0.75rem; }
-    a { color: #58a6ff; text-decoration: none; }
-    a:hover { text-decoration: underline; }
-    .card { background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 1.5rem; margin: 1rem 0; }
-    .status-ok { color: #3fb950; }
-  </style>
+  <link rel="stylesheet" href="/static/style.css">
 </head>
 <body>
+  <nav class="navbar">
+    <a class="nav-brand" href="/dashboard">astro-mcp</a>
+    <div class="nav-links">
+      <a href="/dashboard" class="nav-link">Dashboard</a>
+      <a href="/dashboard/persons" class="nav-link">Persons</a>
+    </div>
+  </nav>
   <div class="container">
     {% block content %}{% endblock %}
   </div>
+  <script src="/static/app.js"></script>
 </body>
 </html>

+ 10 - 4
templates/dashboard.html

@@ -3,9 +3,15 @@
 {% block content %}
 <h1>astro-mcp dashboard</h1>
 <div class="card">
-  <p>Server: <strong>astro-mcp</strong></p>
-  <p>Status: <span class="status-ok">ready</span></p>
-  <p>MCP SSE: <code>/mcp/sse</code></p>
-  <p>Persons: <a href="/dashboard/persons">manage persons</a></p>
+  <div class="detail-grid">
+    <span class="detail-label">Server</span>
+    <span class="detail-value">astro-mcp</span>
+    <span class="detail-label">Status</span>
+    <span class="detail-value status-ok">ready</span>
+    <span class="detail-label">MCP SSE</span>
+    <span class="detail-value"><code>/mcp/sse</code></span>
+    <span class="detail-label">Persons</span>
+    <span class="detail-value"><a href="/dashboard/persons">manage persons</a></span>
+  </div>
 </div>
 {% endblock %}

+ 92 - 0
templates/person-detail.html

@@ -0,0 +1,92 @@
+{% extends "base.html" %}
+{% block title %}{{ person.name }} - astro-mcp{% endblock %}
+{% block content %}
+<h1>{{ person.name }}</h1>
+
+<div class="toolbar">
+  <a href="/dashboard/persons" class="btn btn-ghost">← Back</a>
+  <a href="/dashboard/persons/{{ person.id }}/edit" class="btn btn-primary">Edit</a>
+  <form method="post" action="/dashboard/persons/{{ person.id }}/delete" style="display:inline">
+    <button type="submit" class="btn btn-danger" data-confirm="Delete {{ person.name }}?">Delete</button>
+  </form>
+</div>
+
+<div class="card">
+  <h2>Basic Info</h2>
+  <div class="detail-grid">
+    <span class="detail-label">Full name</span>
+    <span class="detail-value">{{ person.name }}</span>
+
+    <span class="detail-label">Nickname</span>
+    <span class="detail-value">{{ person.nickname or '—' }}</span>
+
+    <span class="detail-label">Description</span>
+    <span class="detail-value">{{ person.description or '—' }}</span>
+
+    <span class="detail-label">Gender</span>
+    <span class="detail-value">
+      {% if person.gender %}<span class="badge badge-blue">{{ person.gender }}</span>{% else %}—{% endif %}
+    </span>
+
+    <span class="detail-label">Status</span>
+    <span class="detail-value">
+      {% if person.alive %}
+      <span class="badge badge-green">alive</span>
+      {% else %}
+      <span class="badge badge-red">deceased</span>
+      {% endif %}
+      {% if person.private %}<span class="badge badge-gray">private</span>{% endif %}
+    </span>
+  </div>
+</div>
+
+<div class="card">
+  <h2>Birth Data</h2>
+  <div class="detail-grid">
+    <span class="detail-label">Birth datetime</span>
+    <span class="detail-value">{{ person.birth_datetime or '—' }}</span>
+
+    <span class="detail-label">Birthplace</span>
+    <span class="detail-value">{{ person.birthplace or '—' }}</span>
+
+    <span class="detail-label">Latitude</span>
+    <span class="detail-value">{{ person.latitude }}</span>
+
+    <span class="detail-label">Longitude</span>
+    <span class="detail-value">{{ person.longitude }}</span>
+
+    <span class="detail-label">Elevation</span>
+    <span class="detail-value">{{ person.elevation }} m</span>
+
+    <span class="detail-label">Timezone</span>
+    <span class="detail-value">{{ person.timezone or '—' }}</span>
+
+    <span class="detail-label">Time known</span>
+    <span class="detail-value">
+      {% if person.birth_time_known %}
+      <span class="badge badge-green">yes</span>
+      {% else %}
+      <span class="badge badge-gray">unknown / approximate</span>
+      {% endif %}
+    </span>
+  </div>
+</div>
+
+{% if person.notes %}
+<div class="card">
+  <h2>Notes</h2>
+  <p style="white-space:pre-wrap">{{ person.notes }}</p>
+</div>
+{% endif %}
+
+<div class="card" style="color:#6e7681;font-size:0.8rem">
+  <div class="detail-grid">
+    <span class="detail-label">ID</span>
+    <span class="detail-value"><code>{{ person.id }}</code></span>
+    <span class="detail-label">Created</span>
+    <span class="detail-value">{{ person.created_at or '—' }}</span>
+    <span class="detail-label">Updated</span>
+    <span class="detail-value">{{ person.updated_at or '—' }}</span>
+  </div>
+</div>
+{% endblock %}

+ 143 - 0
templates/person-form.html

@@ -0,0 +1,143 @@
+{% extends "base.html" %}
+{% block title %}{% if action == 'add' %}Add Person{% else %}Edit {{ person.name }}{% endif %} - astro-mcp{% endblock %}
+{% block content %}
+<h1>{% if action == 'add' %}Add Person{% else %}Edit {{ person.name }}{% endif %}</h1>
+
+<div class="toolbar">
+  {% if action == 'edit' %}
+  <a href="/dashboard/persons/{{ person.id }}" class="btn btn-ghost">← Back</a>
+  {% else %}
+  <a href="/dashboard/persons" class="btn btn-ghost">← Back</a>
+  {% endif %}
+</div>
+
+<form method="post"
+  {% if action == 'add' %}
+  action="/dashboard/persons"
+  {% else %}
+  action="/dashboard/persons/{{ person.id }}"
+  {% endif %}
+>
+  <div class="card">
+    <h2>Identity</h2>
+    <div class="form-row">
+      <div class="form-group">
+        <label for="name">Full Name *</label>
+        <input type="text" id="name" name="name" required
+          value="{{ person.name if person else '' }}">
+      </div>
+      <div class="form-group">
+        <label for="nickname">Nickname</label>
+        <input type="text" id="nickname" name="nickname"
+          value="{{ person.nickname if person else '' }}">
+        <div class="form-hint">Short name for quick lookup</div>
+      </div>
+    </div>
+    <div class="form-group">
+      <label for="description">Description</label>
+      <input type="text" id="description" name="description"
+        value="{{ person.description if person else '' }}"
+        placeholder="e.g. My sister, Client A, Famous person...">
+    </div>
+    <div class="form-row">
+      <div class="form-group">
+        <label for="gender">Gender</label>
+        <select id="gender" name="gender">
+          <option value="" {% if not person or not person.gender %}selected{% endif %}>—</option>
+          <option value="male" {% if person and person.gender == 'male' %}selected{% endif %}>male</option>
+          <option value="female" {% if person and person.gender == 'female' %}selected{% endif %}>female</option>
+          <option value="other" {% if person and person.gender == 'other' %}selected{% endif %}>other</option>
+        </select>
+      </div>
+      <div class="form-group">
+        <label for="alive">Status</label>
+        <select id="alive" name="alive">
+          <option value="true" {% if not person or person.alive %}selected{% endif %}>Alive</option>
+          <option value="false" {% if person and not person.alive %}selected{% endif %}>Deceased</option>
+        </select>
+      </div>
+      <div class="form-group">
+        <label for="private">Visibility</label>
+        <select id="private" name="private">
+          <option value="false" {% if not person or not person.private %}selected{% endif %}>Visible</option>
+          <option value="true" {% if person and person.private %}selected{% endif %}>Private</option>
+        </select>
+      </div>
+    </div>
+  </div>
+
+  <div class="card">
+    <h2>Birth Data *</h2>
+    <div class="form-row">
+      <div class="form-group">
+        <label for="birth_datetime">Birth Datetime *</label>
+        <input type="text" id="birth_datetime" name="birth_datetime" required
+          value="{{ person.birth_datetime if person else '' }}"
+          placeholder="2000-01-01T12:00:00+01:00">
+        <div class="form-hint">ISO 8601 with timezone, e.g. 2000-01-01T12:00:00+01:00</div>
+      </div>
+      <div class="form-group">
+        <label for="birthplace">Birthplace</label>
+        <input type="text" id="birthplace" name="birthplace"
+          value="{{ person.birthplace if person else '' }}"
+          placeholder="e.g. Graz, Austria">
+      </div>
+    </div>
+    <div class="form-row">
+      <div class="form-group">
+        <label for="latitude">Latitude *</label>
+        <input type="number" id="latitude" name="latitude" step="any" required
+          value="{{ person.latitude if person else '' }}"
+          placeholder="47.07">
+      </div>
+      <div class="form-group">
+        <label for="longitude">Longitude *</label>
+        <input type="number" id="longitude" name="longitude" step="any" required
+          value="{{ person.longitude if person else '' }}"
+          placeholder="15.42">
+      </div>
+      <div class="form-group">
+        <label for="elevation">Elevation (m)</label>
+        <input type="number" id="elevation" name="elevation" step="any"
+          value="{{ person.elevation if person else '0' }}">
+      </div>
+    </div>
+    <div class="form-row">
+      <div class="form-group">
+        <label for="tz">Timezone</label>
+        <input type="text" id="tz" name="tz"
+          value="{{ person.timezone if person else '' }}"
+          placeholder="e.g. Europe/Vienna">
+        <div class="form-hint">IANA timezone name</div>
+      </div>
+      <div class="form-group">
+        <label for="birth_time_known">Birth Time Known?</label>
+        <select id="birth_time_known" name="birth_time_known">
+          <option value="true" {% if not person or person.birth_time_known %}selected{% endif %}>Yes, accurate</option>
+          <option value="false" {% if person and not person.birth_time_known %}selected{% endif %}>Unknown / approximate</option>
+        </select>
+      </div>
+    </div>
+  </div>
+
+  <div class="card">
+    <h2>Notes</h2>
+    <div class="form-group">
+      <textarea id="notes" name="notes"
+        placeholder="Freeform notes about this person..."
+      >{{ person.notes if person else '' }}</textarea>
+    </div>
+  </div>
+
+  <div class="form-actions">
+    <button type="submit" class="btn btn-primary">
+      {% if action == 'add' %}Add Person{% else %}Save Changes{% endif %}
+    </button>
+    {% if action == 'edit' %}
+    <a href="/dashboard/persons/{{ person.id }}" class="btn btn-ghost">Cancel</a>
+    {% else %}
+    <a href="/dashboard/persons" class="btn btn-ghost">Cancel</a>
+    {% endif %}
+  </div>
+</form>
+{% endblock %}

+ 67 - 4
templates/persons.html

@@ -1,9 +1,72 @@
 {% extends "base.html" %}
 {% block title %}Persons - astro-mcp{% endblock %}
 {% block content %}
-<h1>persons</h1>
-<p><a href="/dashboard">← back to dashboard</a></p>
-<div class="card">
-  <p>person management coming soon.</p>
+<h1>Persons</h1>
+
+<div class="toolbar">
+  <a href="/dashboard/persons/new" class="btn btn-primary">+ Add Person</a>
+  <input
+    class="search-input"
+    type="text"
+    id="search"
+    placeholder="Search name or nickname..."
+    value="{{ q }}"
+  >
 </div>
+
+{% if persons %}
+<table>
+  <thead>
+    <tr>
+      <th>Name</th>
+      <th>Nickname</th>
+      <th>Birth</th>
+      <th>Location</th>
+      <th>Gender</th>
+      <th>Status</th>
+      <th></th>
+    </tr>
+  </thead>
+  <tbody id="person-rows">
+    {% for p in persons %}
+    <tr>
+      <td><a href="/dashboard/persons/{{ p.id }}">{{ p.name }}</a></td>
+      <td>{{ p.nickname or '—' }}</td>
+      <td>{{ p.birth_datetime[:10] if p.birth_datetime else '—' }}</td>
+      <td>{{ p.birthplace or '—' }}</td>
+      <td>
+        {% if p.gender %}
+        <span class="badge badge-blue">{{ p.gender }}</span>
+        {% else %}
+        —
+        {% endif %}
+      </td>
+      <td>
+        {% if p.alive %}
+        <span class="badge badge-green">alive</span>
+        {% else %}
+        <span class="badge badge-red">deceased</span>
+        {% endif %}
+        {% if p.private %}
+        <span class="badge badge-gray">private</span>
+        {% endif %}
+        {% if p.birth_time_known == False %}
+        <span class="badge badge-gray">time unknown</span>
+        {% endif %}
+      </td>
+      <td style="white-space:nowrap">
+        <a href="/dashboard/persons/{{ p.id }}/edit" class="btn btn-ghost btn-sm">Edit</a>
+        <form method="post" action="/dashboard/persons/{{ p.id }}/delete" style="display:inline">
+          <button type="submit" class="btn btn-danger btn-sm" data-confirm="Delete {{ p.name }}?">Delete</button>
+        </form>
+      </td>
+    </tr>
+    {% endfor %}
+  </tbody>
+</table>
+{% else %}
+<div class="empty card">
+  <p>No persons yet. <a href="/dashboard/persons/new">Add one</a>.</p>
+</div>
+{% endif %}
 {% endblock %}