Files
CTXD/app/src/ctxd/ui.html
T
overseer fc1a2f5103 feat: PostgreSQL migration, OAuth write MCP, Streamable HTTP, env-driven config, admin UI, landing page
- Migrate database from SQLite to PostgreSQL 16 (dual-backend with SQLite fallback)
- Add Streamable HTTP MCP transport (replaces SSE): /readonly/mcp, /write/mcp, /mcp
- Add OAuth ctxd.write scope and public write MCP surface
- Add ctxd.write token validation (write-scoped tokens only on /write/mcp)
- Add env-driven configuration (.env file with env var precedence over ctxd.yaml)
- Add PostgreSQL to docker-compose.yml with healthcheck
- Add psycopg dependency, migration script (SQLite → PostgreSQL)
- Add admin UI: projects tab with typed-confirm delete, user management (list/manage subtabs)
- Add OAuth client management: create, list, revoke (UI, CLI, API)
- Add user active/inactive lifecycle (PATCH/DELETE APIs)
- Add public landing page with themed login form (cookie-based session)
- Add get_client_guide MCP tool (locked LLM-CLIENT.MD in ctxd-docs project)
- Add DELETE /projects/<id> endpoint with cascading deletes
- Add project_delete to db.py with FK ON DELETE SET NULL for audit_log
- Add cookie-based session auth (ctxd_session cookie on login)
- Add landing.html (public host) vs ui.html (internal dashboard)
- Add schema_sqlite.sql for SQLite fallback
- Add auth_password.py (PBKDF2-SHA256 password hashing)
- Add .env.example template with all documented env vars
- Add README.md with full setup, config, API, CLI, and troubleshooting docs
- Add SKILL.md (canonical LLM client guide, lives in project root)
- Update Traefik template: route everything except /mcp
- Update OAuth discovery: advertise ctxd.write scope, /readonly/mcp resource
- Update Hermes MCP config: /mcp endpoint with Bearer header
- Remove DB-level audit_log triggers (conflict with FK ON DELETE SET NULL)
- Remove SSE transport code (replaced by Streamable HTTP)
- Untrack __pycache__ and data/ctxd.db from git
2026-06-24 22:50:54 +00:00

2483 lines
84 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>CTXD</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,600;0,700;1,400&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<style>
/* ── Reset & base ─────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #1a1a1a;
--paper: #1e1e1e;
--ink: #d4d4d4;
--ink-dim: #888;
--ink-bright: #e0e0e0;
--accent: #e5c07b;
--accent2: #7ec8e3;
--accent3: #98c379;
--accent4: #c678dd;
--border: #2a2a2a;
--border-light: #333;
--header-bg: #141414;
--input-bg: #222;
--hover: #252525;
--danger: #e06c75;
--font: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'IBM Plex Mono', monospace;
--line: 1.6;
--col-gap: 2rem;
--sidebar-w: 240px;
--touch-min: 44px;
}
html {
height: 100%;
font-size: 15px;
-webkit-text-size-adjust: 100%;
}
body {
font-family: var(--font);
background: var(--bg);
color: var(--ink);
line-height: var(--line);
height: 100vh;
height: 100dvh;
max-height: 100dvh;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ── Masthead ──────────────────────────────────────────────────── */
.masthead {
background: var(--header-bg);
border-bottom: 3px double var(--border-light);
padding: 0.75rem 1rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
user-select: none;
flex-shrink: 0;
min-height: var(--touch-min);
}
.masthead h1 {
font-size: 1.1rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--accent);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.masthead h1 span { color: var(--ink-dim); font-weight: 400; }
.masthead .edition {
font-size: 0.65rem;
color: var(--ink-dim);
text-transform: uppercase;
letter-spacing: 0.08em;
white-space: nowrap;
display: flex;
align-items: center;
gap: 0.5rem;
}
.masthead .admin-btn {
font-family: var(--font);
font-size: 0.65rem;
background: none;
border: 1px solid var(--border-light);
color: var(--accent);
padding: 0.25rem 0.5rem;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 0.08em;
min-height: 2rem;
}
.masthead .admin-btn:hover { background: var(--hover); border-color: var(--accent); }
.masthead .edition .dot {
display: inline-block;
width: 6px; height: 6px;
border-radius: 50%;
background: var(--accent3);
margin-right: 0.3rem;
vertical-align: middle;
animation: pulse 2s infinite;
}
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
/* Hamburger — visible only on mobile */
.hamburger {
display: none;
background: none;
border: none;
color: var(--ink);
font-size: 1.3rem;
cursor: pointer;
padding: 0.4rem;
line-height: 1;
min-width: var(--touch-min);
min-height: var(--touch-min);
align-items: center;
justify-content: center;
}
.hamburger:hover { color: var(--accent); }
/* ── Layout ────────────────────────────────────────────────────── */
.layout {
display: flex;
flex: 1;
min-height: 0;
position: relative;
}
.sidebar {
width: var(--sidebar-w);
flex-shrink: 0;
background: var(--paper);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
transition: transform 0.25s ease;
}
.main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
min-height: 0;
}
/* ── Sidebar ───────────────────────────────────────────────────── */
.sidebar-header {
padding: 0.6rem 0.75rem;
border-bottom: 1px solid var(--border);
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--ink-dim);
display: flex;
justify-content: space-between;
align-items: center;
min-height: var(--touch-min);
}
.sidebar-header .btn-new {
font-family: var(--font);
font-size: 0.65rem;
background: none;
border: 1px solid var(--border-light);
color: var(--accent);
padding: 0.3rem 0.6rem;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 0.08em;
min-height: var(--touch-min);
display: flex;
align-items: center;
}
.sidebar-header .btn-new:hover { background: var(--hover); }
.project-list {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 0.25rem 0;
}
.project-item {
padding: 0.6rem 0.75rem;
cursor: pointer;
border-left: 3px solid transparent;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.8rem;
min-height: var(--touch-min);
-webkit-tap-highlight-color: transparent;
}
.project-item:hover { background: var(--hover); }
.project-item:active { background: var(--hover); }
.project-item.active {
background: var(--hover);
border-left-color: var(--accent);
color: var(--accent);
}
.project-item .chevron {
color: var(--ink-dim);
width: 0.9rem;
flex-shrink: 0;
}
.project-item .version {
font-size: 0.65rem;
color: var(--ink-dim);
flex-shrink: 0;
margin-left: 0.5rem;
}
.project-item .name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.project-files {
margin: 0.1rem 0 0.35rem 0;
border-left: 1px solid var(--border);
margin-left: 1.25rem;
}
.project-file-item {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.4rem;
padding: 0.35rem 0.75rem 0.35rem 0.65rem;
cursor: pointer;
font-size: 0.72rem;
color: var(--ink-dim);
min-height: 2rem;
border-left: 2px solid transparent;
-webkit-tap-highlight-color: transparent;
}
.project-file-item:hover { background: var(--hover); color: var(--ink); }
.project-file-item.active {
color: var(--accent);
border-left-color: var(--accent);
background: rgba(229, 192, 123, 0.08);
}
.project-file-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-file-version {
font-size: 0.58rem;
color: var(--ink-dim);
flex-shrink: 0;
}
.project-files-loading {
padding: 0.35rem 0.75rem 0.35rem 0.65rem;
color: var(--ink-dim);
font-size: 0.68rem;
font-style: italic;
}
.sidebar-footer {
padding: 0.4rem 0.75rem;
border-top: 1px solid var(--border);
font-size: 0.65rem;
color: var(--ink-dim);
display: flex;
gap: 0.25rem;
flex-wrap: wrap;
}
.sidebar-footer button {
font-family: var(--font);
font-size: 0.65rem;
background: none;
border: none;
color: var(--ink-dim);
cursor: pointer;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 0.3rem 0.4rem;
min-height: var(--touch-min);
display: flex;
align-items: center;
}
.sidebar-footer button:hover { color: var(--accent); }
.sidebar-footer button.active-tab { color: var(--accent); }
/* ── Search bar ────────────────────────────────────────────────── */
.search-bar {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--border);
display: flex;
gap: 0.4rem;
align-items: center;
flex-shrink: 0;
}
.search-bar input {
flex: 1;
min-width: 0;
font-family: var(--font);
font-size: 0.8rem;
background: var(--input-bg);
border: 1px solid var(--border-light);
color: var(--ink);
padding: 0.4rem 0.6rem;
outline: none;
min-height: var(--touch-min);
-webkit-appearance: none;
border-radius: 0;
}
.search-bar input:focus { border-color: var(--accent); }
.search-bar input::placeholder { color: var(--ink-dim); font-style: italic; }
.search-bar .search-hint {
font-size: 0.65rem;
color: var(--ink-dim);
white-space: nowrap;
flex-shrink: 0;
}
/* ── Content area ──────────────────────────────────────────────── */
.content {
flex: 1;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
touch-action: pan-y;
padding: 1rem;
display: block;
}
/* ── File tabs ─────────────────────────────────────────────────── */
.file-tabs {
display: flex;
gap: 0;
overflow-x: auto;
border-bottom: 1px solid var(--border);
margin-bottom: 0.5rem;
flex-shrink: 0;
-webkit-overflow-scrolling: touch;
}
.file-tab {
font-family: var(--font);
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.06em;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--ink-dim);
padding: 0.5rem 0.75rem;
cursor: pointer;
white-space: nowrap;
display: flex;
align-items: center;
gap: 0.3rem;
min-height: var(--touch-min);
-webkit-tap-highlight-color: transparent;
}
.file-tab:hover { color: var(--ink); background: var(--hover); }
.file-tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
.file-tab-version {
font-size: 0.55rem;
color: var(--ink-dim);
background: var(--input-bg);
padding: 0.1rem 0.25rem;
border-radius: 2px;
}
.file-tab.active .file-tab-version { color: var(--accent); }
.file-tab-add {
color: var(--accent2);
font-size: 1rem;
padding: 0.5rem 0.6rem;
}
/* ── Article body — single column ──────────────────────────────── */
.newspaper {
display: block;
overflow: visible;
padding-right: 0.25rem;
padding-bottom: env(safe-area-inset-bottom, 0);
column-count: 1;
column-gap: 0;
column-rule: none;
}
.newspaper h1 { column-span: none; }
.newspaper h2 { column-span: none; }
.newspaper h3 { break-inside: avoid; }
.newspaper pre { break-inside: avoid; }
.newspaper table { break-inside: avoid; }
.newspaper img { max-width: 100%; height: auto; }
/* ── Markdown content ──────────────────────────────────────────── */
.content h1 {
font-size: 1.3rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--ink-bright);
margin: 0 0 0.6rem 0;
padding-bottom: 0.4rem;
border-bottom: 2px solid var(--border-light);
word-break: break-word;
}
.content h2 {
font-size: 1.05rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--accent);
margin: 1.2rem 0 0.4rem 0;
word-break: break-word;
}
.content h3 {
font-size: 0.95rem;
font-weight: 600;
color: var(--accent2);
margin: 0.8rem 0 0.3rem 0;
word-break: break-word;
}
.content p { margin: 0 0 0.6rem 0; word-break: break-word; }
.content a { color: var(--accent2); text-decoration: underline; text-underline-offset: 2px; word-break: break-word; }
.content a:hover { color: var(--accent); }
.content strong { color: var(--ink-bright); }
.content em { color: var(--ink-dim); }
.content blockquote {
border-left: 3px solid var(--accent);
padding: 0.4rem 0.75rem;
margin: 0.6rem 0;
background: var(--input-bg);
font-style: italic;
color: var(--ink-dim);
}
.content ul, .content ol { margin: 0 0 0.6rem 1.2rem; }
.content li { margin: 0.2rem 0; word-break: break-word; }
.content hr {
border: none;
border-top: 1px solid var(--border-light);
margin: 1.2rem 0;
}
.content code {
font-family: var(--font);
font-size: 0.85em;
background: var(--input-bg);
padding: 0.1em 0.3em;
border: 1px solid var(--border);
border-radius: 2px;
word-break: break-word;
}
.content pre {
background: var(--input-bg);
border: 1px solid var(--border);
padding: 0.6rem 0.75rem;
margin: 0.6rem 0;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
font-size: 0.75rem;
line-height: 1.4;
}
.content pre code {
background: none;
border: none;
padding: 0;
font-size: inherit;
word-break: normal;
white-space: pre;
}
.content table {
width: 100%;
border-collapse: collapse;
margin: 0.6rem 0;
font-size: 0.8rem;
display: block;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.content th, .content td {
border: 1px solid var(--border);
padding: 0.35rem 0.5rem;
text-align: left;
white-space: nowrap;
}
.content th {
background: var(--input-bg);
color: var(--accent);
text-transform: uppercase;
letter-spacing: 0.05em;
font-size: 0.7rem;
}
.content tr:nth-child(even) { background: rgba(255,255,255,0.02); }
/* ── Metadata header (gold, vault-style) ──────────────────────── */
.ctxd-header {
color: var(--accent);
font-family: var(--font);
font-size: 0.85rem;
line-height: 1.7;
text-transform: uppercase;
letter-spacing: 0.03em;
white-space: pre-wrap;
margin-bottom: 0.5rem;
}
.ctxd-header + hr { margin-top: 0; }
/* ── Editor ─────────────────────────────────────────────────────── */
.editor-pane { display: none; }
.editor-pane.active {
display: block;
min-height: 0;
overflow: visible;
}
.editor-toolbar {
display: flex;
gap: 0.4rem;
padding: 0.4rem 0;
border-bottom: 1px solid var(--border);
margin-bottom: 0.6rem;
align-items: center;
flex-wrap: wrap;
flex-shrink: 0;
}
.editor-toolbar .meta {
font-size: 0.65rem;
color: var(--ink-dim);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.editor-toolbar .meta strong { color: var(--ink-bright); }
.editor-toolbar .spacer { flex: 1; min-width: 0.5rem; }
.editor-toolbar button {
font-family: var(--font);
font-size: 0.7rem;
background: none;
border: 1px solid var(--border-light);
color: var(--ink);
padding: 0.3rem 0.6rem;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 0.08em;
min-height: var(--touch-min);
display: flex;
align-items: center;
-webkit-tap-highlight-color: transparent;
}
.editor-toolbar button:hover { background: var(--hover); border-color: var(--accent); color: var(--accent); }
.editor-toolbar button.primary {
background: var(--accent);
color: var(--bg);
border-color: var(--accent);
font-weight: 600;
}
.editor-toolbar button.primary:hover { background: #d4ae5c; }
.editor-toolbar button.danger { color: var(--danger); border-color: var(--danger); }
.editor-toolbar button.danger:hover { background: var(--danger); color: var(--bg); }
.editor-split {
display: flex;
flex: 1;
gap: 0.75rem;
min-height: 0;
}
.editor-split textarea {
flex: 1;
font-family: var(--font);
font-size: 0.8rem;
line-height: 1.6;
background: var(--input-bg);
border: 1px solid var(--border-light);
color: var(--ink);
padding: 0.6rem;
resize: none;
outline: none;
min-height: 200px;
-webkit-appearance: none;
border-radius: 0;
}
.editor-split textarea:focus { border-color: var(--accent); }
.editor-preview {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 0 0.6rem;
border-left: 1px solid var(--border);
}
/* ── Search results ────────────────────────────────────────────── */
.search-results { display: none; }
.search-results.active { display: block; }
.search-result-item {
padding: 0.6rem 0.75rem;
border-bottom: 1px solid var(--border);
cursor: pointer;
min-height: var(--touch-min);
-webkit-tap-highlight-color: transparent;
}
.search-result-item:hover { background: var(--hover); }
.search-result-item:active { background: var(--hover); }
.search-result-item .src {
font-size: 0.65rem;
color: var(--ink-dim);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.search-result-item .path { color: var(--accent2); font-size: 0.8rem; word-break: break-word; }
.search-result-item .snippet {
font-size: 0.75rem;
color: var(--ink-dim);
margin-top: 0.2rem;
line-height: 1.4;
word-break: break-word;
}
.search-result-item .snippet em { color: var(--accent); font-style: normal; background: rgba(229,192,123,0.15); padding: 0 2px; }
/* ── Audit log ─────────────────────────────────────────────────── */
.audit-table { width: 100%; font-size: 0.75rem; display: block; overflow-x: auto; -webkit-overflow-scrolling: touch; }
.audit-table th {
position: sticky;
top: 0;
background: var(--paper);
white-space: nowrap;
}
.audit-table td { white-space: nowrap; }
/* ── New project dialog ────────────────────────────────────────── */
.dialog-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
z-index: 100;
align-items: center;
justify-content: center;
padding: 1rem;
}
.dialog-overlay.active { display: flex; }
.dialog {
background: var(--paper);
border: 1px solid var(--border-light);
padding: 1.25rem;
width: 100%;
max-width: 420px;
}
.dialog h2 {
font-size: 0.95rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--accent);
margin-bottom: 0.75rem;
}
.dialog label {
display: block;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--ink-dim);
margin-bottom: 0.2rem;
}
.dialog input,
.dialog select {
width: 100%;
font-family: var(--font);
font-size: 0.85rem;
background: var(--input-bg);
border: 1px solid var(--border-light);
color: var(--ink);
padding: 0.4rem 0.6rem;
margin-bottom: 0.6rem;
outline: none;
min-height: var(--touch-min);
-webkit-appearance: none;
border-radius: 0;
}
.dialog input:focus,
.dialog select:focus { border-color: var(--accent); }
.admin-users {
margin: 0.75rem 0 1rem 0;
max-height: 220px;
overflow-y: auto;
border: 1px solid var(--border);
}
.admin-user-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.35rem 0.75rem;
padding: 0.55rem 0.65rem;
border-bottom: 1px solid var(--border);
font-size: 0.75rem;
}
.admin-user-row:last-child { border-bottom: none; }
.admin-user-row .user-id { color: var(--accent); font-weight: 600; }
.admin-user-row .user-name { color: var(--ink); }
.admin-user-row .user-role { color: var(--ink-dim); text-transform: uppercase; font-size: 0.65rem; }
.admin-user-row .user-date { color: var(--ink-dim); font-size: 0.65rem; }
.admin-section {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--accent);
margin: 1rem 0 0.35rem 0;
}
.admin-divider { border: none; border-top: 1px solid var(--border); margin: 1rem 0; }
/* Admin dialog: tabs + scroll */
.dialog.dialog-admin {
max-width: min(92vw, 640px);
width: 100%;
max-height: min(88vh, 600px);
display: flex;
flex-direction: column;
overflow: hidden;
padding: 1rem 1.15rem 0.85rem;
}
.dialog.dialog-admin > h2 {
flex-shrink: 0;
margin-bottom: 0.5rem;
}
.admin-tabs {
display: flex;
flex-shrink: 0;
gap: 0;
border-bottom: 1px solid var(--border);
margin-bottom: 0;
}
.admin-tab {
flex: 1;
font-family: var(--font);
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 0.55rem 0.5rem;
border: none;
border-bottom: 2px solid transparent;
background: none;
color: var(--ink-dim);
cursor: pointer;
min-height: var(--touch-min);
}
.admin-tab:hover { color: var(--ink); background: var(--hover); }
.admin-tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
font-weight: 600;
}
.admin-tab-panels {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.admin-tab-panel {
display: none;
flex: 1;
flex-direction: column;
min-height: 0;
overflow: hidden;
padding: 0.75rem 0 0.5rem;
}
.admin-tab-panel.active {
display: flex;
}
.admin-tab-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
padding-right: 0.15rem;
}
#admin-panel-users .admin-subtabs,
#admin-panel-oauth .admin-subtabs,
#admin-panel-projects .admin-subtabs {
flex-shrink: 0;
}
.admin-users-subpanel,
.admin-oauth-subpanel,
.admin-projects-subpanel {
display: none;
}
.admin-users-subpanel.active,
.admin-oauth-subpanel.active,
.admin-projects-subpanel.active {
display: block;
}
.admin-subtabs {
display: flex;
gap: 0.25rem;
margin-bottom: 0.5rem;
flex-shrink: 0;
}
.admin-subtab {
font-family: var(--font);
font-size: 0.6rem;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 0.35rem 0.55rem;
border: 1px solid var(--border);
background: none;
color: var(--ink-dim);
cursor: pointer;
}
.admin-subtab.active {
border-color: var(--accent);
color: var(--accent);
}
.user-list-table {
width: 100%;
border-collapse: collapse;
font-size: 0.7rem;
}
.user-list-table th,
.user-list-table td {
text-align: left;
padding: 0.45rem 0.4rem;
border-bottom: 1px solid var(--border);
}
.user-list-table th {
font-size: 0.6rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--ink-dim);
}
.user-state-active { color: var(--accent3); }
.user-state-inactive { color: var(--danger); }
/* OAuth client list — compact, matches user table density */
#admin-oauth-list.admin-oauth-list {
font-size: 0.65rem;
line-height: 1.45;
}
.admin-oauth-row {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 0.5rem 0.75rem;
padding: 0.45rem 0.55rem;
border-bottom: 1px solid var(--border);
}
.admin-oauth-row:last-child { border-bottom: none; }
.admin-oauth-row .oauth-client-meta {
min-width: 0;
}
.admin-oauth-row .oauth-client-id {
color: var(--accent);
font-weight: 600;
font-size: 0.62rem;
word-break: break-all;
line-height: 1.35;
}
.admin-oauth-row .oauth-client-name {
color: var(--ink);
font-size: 0.65rem;
margin-top: 0.15rem;
}
.admin-oauth-row .oauth-client-uri {
color: var(--ink-dim);
font-size: 0.58rem;
margin-top: 0.2rem;
word-break: break-all;
line-height: 1.35;
}
.dialog.dialog-admin button.oauth-revoke,
.dialog.dialog-admin .manage-user-actions button {
font-family: var(--font);
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 0.3rem 0.55rem;
min-height: 2rem;
cursor: pointer;
border: 1px solid var(--border-light);
background: var(--input-bg);
color: var(--ink);
border-radius: 0;
-webkit-appearance: none;
}
.dialog.dialog-admin .manage-user-actions button:hover {
background: var(--hover);
border-color: var(--accent);
color: var(--accent);
}
.dialog.dialog-admin .manage-user-actions button.primary {
background: var(--accent);
color: var(--bg);
border-color: var(--accent);
}
.dialog.dialog-admin .manage-user-actions button.primary:hover {
background: #d4ae5c;
border-color: #d4ae5c;
color: var(--bg);
}
.dialog.dialog-admin button.oauth-revoke {
border-color: var(--border-light);
color: var(--danger);
background: none;
align-self: center;
}
.dialog.dialog-admin button.oauth-revoke:hover {
background: var(--hover);
border-color: var(--danger);
color: var(--danger);
}
.manage-user-actions {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-top: 0.5rem;
padding-bottom: 0.75rem;
}
.dialog label.checkbox-row {
display: flex;
align-items: center;
gap: 0.5rem;
text-transform: none;
letter-spacing: 0;
font-size: 0.75rem;
color: var(--ink);
margin: 0.5rem 0;
}
.dialog label.checkbox-row input { width: auto; }
.admin-dialog-footer {
flex-shrink: 0;
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--border);
}
.dialog.dialog-admin .admin-users {
max-height: 160px;
}
.oauth-credentials {
margin: 0.75rem 0;
padding: 0.65rem;
background: var(--input-bg);
border: 1px solid var(--border);
font-size: 0.65rem;
line-height: 1.45;
white-space: pre-wrap;
word-break: break-all;
max-height: 200px;
overflow-y: auto;
}
.dialog .actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
margin-top: 0.5rem;
}
.dialog .actions button {
font-family: var(--font);
font-size: 0.7rem;
padding: 0.4rem 0.75rem;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 0.08em;
border: 1px solid var(--border-light);
background: none;
color: var(--ink);
min-height: var(--touch-min);
display: flex;
align-items: center;
}
.dialog .actions button:hover { background: var(--hover); }
.dialog .actions button.primary {
background: var(--accent);
color: var(--bg);
border-color: var(--accent);
}
/* ── Import / file upload ──────────────────────────────────────── */
.import-section { margin-bottom: 0.6rem; }
.file-drop {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
border: 1px dashed var(--border-light);
background: var(--input-bg);
min-height: var(--touch-min);
}
.file-btn {
font-family: var(--font);
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
border: 1px solid var(--border-light);
background: none;
color: var(--ink);
padding: 0.3rem 0.6rem;
cursor: pointer;
min-height: var(--touch-min);
display: flex;
align-items: center;
flex-shrink: 0;
}
.file-btn:hover { background: var(--hover); border-color: var(--accent); color: var(--accent); }
.file-name {
font-size: 0.75rem;
color: var(--ink-dim);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dialog-hint {
font-size: 0.7rem;
color: var(--ink-dim);
margin-bottom: 0.6rem;
line-height: 1.5;
}
/* ── Toast ──────────────────────────────────────────────────────── */
.toast {
position: fixed;
bottom: 1rem;
right: 1rem;
left: 1rem;
background: var(--paper);
border: 1px solid var(--border-light);
padding: 0.6rem 0.75rem;
font-size: 0.75rem;
color: var(--ink);
z-index: 200;
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
max-width: 400px;
margin: 0 auto;
}
.toast.show { opacity: 1; }
.toast.error { border-color: var(--danger); color: var(--danger); }
.toast.success { border-color: var(--accent3); color: var(--accent3); }
/* ── Loading ───────────────────────────────────────────────────── */
.loading {
text-align: center;
padding: 2rem 1rem;
color: var(--ink-dim);
font-style: italic;
}
/* ── Scrollbar ─────────────────────────────────────────────────── */
::-webkit-scrollbar { width: 4px; height: 4px; }
::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb { background: var(--border-light); }
::-webkit-scrollbar-thumb:hover { background: var(--ink-dim); }
/* ── Empty state ───────────────────────────────────────────────── */
.empty-state {
text-align: center;
padding: 2rem 1rem;
color: var(--ink-dim);
font-style: italic;
}
/* ── Sidebar overlay (mobile) ──────────────────────────────────── */
.sidebar-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
z-index: 50;
}
/* ═══════════════════════════════════════════════════════════════════
RESPONSIVE — Mobile first, layered up
═══════════════════════════════════════════════════════════════════ */
/* ── Small phones (< 480px) ────────────────────────────────────── */
@media (max-width: 479px) {
html { font-size: 14px; }
.masthead { padding: 0.5rem 0.6rem; }
.masthead h1 { font-size: 0.95rem; letter-spacing: 0.1em; }
.masthead h1 span { display: none; }
.masthead .edition { font-size: 0.6rem; }
.content { padding: 0.6rem; }
.search-bar { padding: 0.4rem 0.5rem; }
.search-bar input { font-size: 0.75rem; }
.editor-split { flex-direction: column; }
.editor-split textarea { min-height: 150px; }
.editor-preview { border-left: none; border-top: 1px solid var(--border); padding: 0.6rem 0 0 0; }
.editor-toolbar button { font-size: 0.65rem; padding: 0.25rem 0.5rem; }
.newspaper { column-count: 1; }
.sidebar { width: 100%; max-width: 280px; }
.dialog { padding: 1rem; }
}
/* ── Phones & small tablets (480px 767px) ───────────────────── */
@media (max-width: 767px) {
.hamburger { display: flex; }
.sidebar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
z-index: 60;
transform: translateX(-100%);
width: 80vw;
max-width: 300px;
}
.sidebar.open { transform: translateX(0); }
.sidebar-overlay.active { display: block; }
.newspaper { column-count: 1; }
.editor-split { flex-direction: column; }
.editor-split textarea { min-height: 180px; }
.editor-preview { border-left: none; border-top: 1px solid var(--border); padding: 0.6rem 0 0 0; }
.masthead h1 span { display: none; }
}
/* ── Tablets portrait (768px 1023px) ─────────────────────────── */
@media (min-width: 768px) and (max-width: 1023px) {
html { font-size: 14.5px; }
.sidebar { width: 200px; }
.content { padding: 1rem 1.25rem; }
.newspaper { column-count: 1; column-gap: 0; column-rule: none; }
.editor-split textarea { min-height: 250px; }
.masthead h1 { font-size: 1.05rem; }
}
/* ── Tablets landscape (1024px 1279px) ────────────────────────── */
@media (min-width: 1024px) and (max-width: 1279px) {
.sidebar { width: 220px; }
.content { padding: 1.25rem 1.5rem; }
}
/* ── Large desktop (> 1280px) ──────────────────────────────────── */
@media (min-width: 1280px) {
.content { padding: 1.5rem 2rem; }
}
/* ── Touch-friendly: hide hover on touch devices ────────────────── */
@media (hover: none) {
.project-item:hover { background: none; }
.project-item:active { background: var(--hover); }
.search-result-item:hover { background: none; }
.search-result-item:active { background: var(--hover); }
.editor-toolbar button:hover { background: none; border-color: var(--border-light); color: var(--ink); }
.editor-toolbar button:active { background: var(--hover); }
.sidebar-footer button:hover { color: var(--ink-dim); }
.sidebar-footer button:active { color: var(--accent); }
.dialog .actions button:hover { background: none; }
.dialog .actions button:active { background: var(--hover); }
}
/* ── Safe area insets (iPhone X+) ──────────────────────────────── */
@supports (padding: env(safe-area-inset-bottom)) {
.sidebar { padding-bottom: env(safe-area-inset-bottom); }
.toast { bottom: calc(1rem + env(safe-area-inset-bottom)); }
}
</style>
</head>
<body>
<!-- ── Masthead ── -->
<header class="masthead">
<button class="hamburger" id="hamburger" aria-label="Toggle sidebar"></button>
<h1>📋 CTXD <span>· project context</span></h1>
<div class="edition">
<button class="admin-btn" id="admin-btn" onclick="showAdminDialog()" style="display:none">admin</button>
<button class="admin-btn" id="logout-btn" onclick="logout()" style="display:none">logout</button>
<span><span class="dot"></span><span id="status-text">connecting…</span></span>
</div>
</header>
<!-- ── Layout ── -->
<div class="layout">
<!-- Sidebar overlay (mobile) -->
<div class="sidebar-overlay" id="sidebar-overlay"></div>
<!-- Sidebar -->
<nav class="sidebar" id="sidebar">
<div class="sidebar-header">
<span>projects</span>
<button class="btn-new" onclick="showNewProjectDialog()">+ new</button>
</div>
<div class="project-list" id="project-list"></div>
<div class="sidebar-footer">
<button class="active-tab" data-tab="context" onclick="switchTab('context')">context</button>
<button data-tab="audit" onclick="switchTab('audit')">audit</button>
<button data-tab="snapshots" onclick="switchTab('snapshots')">snapshots</button>
</div>
</nav>
<!-- Main -->
<div class="main">
<div class="search-bar">
<input type="text" id="search-input" placeholder="search context…" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" onkeydown="if(event.key==='Enter')doSearch()">
<span class="search-hint"></span>
</div>
<div class="content" id="content-area">
<div class="loading">select a project from the sidebar</div>
</div>
</div>
</div>
<!-- ── New project dialog ── -->
<div class="dialog-overlay" id="new-project-dialog">
<div class="dialog">
<h2>new project</h2>
<label>project id</label>
<input type="text" id="new-pid" placeholder="e.g. my-project" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" onkeydown="if(event.key==='Enter')createProject()">
<label>display name</label>
<input type="text" id="new-name" placeholder="My Project" onkeydown="if(event.key==='Enter')createProject()">
<label>description</label>
<input type="text" id="new-desc" placeholder="What is this project?" onkeydown="if(event.key==='Enter')createProject()">
<div class="import-section">
<label>import context file (optional)</label>
<div class="file-drop" id="file-drop">
<input type="file" id="import-file" accept=".md,.txt,.cursorrules,.windsurfrules" style="display:none">
<button class="file-btn" onclick="document.getElementById('import-file').click()">choose file</button>
<span class="file-name" id="import-file-name">no file selected</span>
</div>
</div>
<div class="actions">
<button onclick="hideNewProjectDialog()">cancel</button>
<button class="primary" onclick="createProject()">create</button>
</div>
</div>
</div>
<!-- ── Import dialog (existing project) ── -->
<div class="dialog-overlay" id="import-dialog">
<div class="dialog">
<h2>import context</h2>
<p class="dialog-hint">Upload an AGENTS.md, CLAUDE.md, .cursorrules, or any markdown/txt file. Existing headers and YAML frontmatter will be stripped automatically.</p>
<div class="file-drop" id="file-drop-import">
<input type="file" id="import-file-existing" accept=".md,.txt,.cursorrules,.windsurfrules" style="display:none">
<button class="file-btn" onclick="document.getElementById('import-file-existing').click()">choose file</button>
<span class="file-name" id="import-file-name-existing">no file selected</span>
</div>
<div class="actions">
<button onclick="hideImportDialog()">cancel</button>
<button class="primary" onclick="importToExisting()">import</button>
</div>
</div>
</div>
<!-- ── Admin dialog ── -->
<div class="dialog-overlay" id="admin-dialog">
<div class="dialog dialog-admin">
<h2>admin</h2>
<nav class="admin-tabs" role="tablist">
<button type="button" class="admin-tab active" role="tab" aria-selected="true" data-admin-tab="oauth" onclick="switchAdminTab('oauth')">oauth clients</button>
<button type="button" class="admin-tab" role="tab" aria-selected="false" data-admin-tab="users" onclick="switchAdminTab('users')">users</button>
<button type="button" class="admin-tab" role="tab" aria-selected="false" data-admin-tab="projects" onclick="switchAdminTab('projects')">projects</button>
</nav>
<div class="admin-tab-panels">
<div id="admin-panel-oauth" class="admin-tab-panel active" role="tabpanel">
<nav class="admin-subtabs" role="tablist">
<button type="button" class="admin-subtab active" data-oauth-subtab="list" onclick="switchOAuthSubTab('list')">client list</button>
<button type="button" class="admin-subtab" data-oauth-subtab="create" onclick="switchOAuthSubTab('create')">create client</button>
</nav>
<div class="admin-tab-scroll">
<div id="admin-oauth-sub-list" class="admin-oauth-subpanel active">
<div class="admin-users admin-oauth-list" id="admin-oauth-list" style="max-height:none;border:none;overflow:visible;">
<div class="loading">loading oauth clients…</div>
</div>
</div>
<div id="admin-oauth-sub-create" class="admin-oauth-subpanel">
<p class="dialog-hint">Register OAuth clients for any MCP or OAuth consumer. Public connector URL: <code>/readonly/sse</code>. Many clients auto-register via <code>POST /oauth/register</code>; use this form when you need explicit <code>client_id</code> / <code>client_secret</code> (secret shown once).</p>
<label>client name</label>
<input type="text" id="admin-oauth-name" value="MCP connector" autocomplete="off">
<label>redirect uri</label>
<input type="url" id="admin-oauth-redirect" placeholder="https://your-app.example/oauth/callback" autocomplete="off">
<p class="dialog-hint" style="margin-top:0.25rem">Use the callback URL your OAuth client documents.</p>
<div class="actions" style="justify-content: flex-start; margin: 0.5rem 0;">
<button class="primary" type="button" onclick="createOAuthClient()">generate client id / secret</button>
</div>
<pre id="admin-oauth-result" class="oauth-credentials" style="display:none"></pre>
</div>
</div>
</div>
<div id="admin-panel-users" class="admin-tab-panel" role="tabpanel">
<nav class="admin-subtabs" role="tablist">
<button type="button" class="admin-subtab active" data-users-subtab="list" onclick="switchUsersSubTab('list')">user list</button>
<button type="button" class="admin-subtab" data-users-subtab="manage" onclick="switchUsersSubTab('manage')">manage users</button>
</nav>
<div class="admin-tab-scroll">
<div id="admin-users-sub-list" class="admin-users-subpanel active">
<div class="admin-users" id="admin-users-list" style="max-height:none;border:none;overflow:visible;">
<div class="loading">loading users…</div>
</div>
</div>
<div id="admin-users-sub-manage" class="admin-users-subpanel">
<p class="dialog-hint">Create a user or select one to edit. Inactive users cannot sign in.</p>
<label>select user</label>
<select id="admin-manage-select" onchange="onManageUserSelect()">
<option value="">— new user —</option>
</select>
<label>user id</label>
<input type="text" id="admin-user-id" placeholder="e.g. joshua" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false">
<label>display name</label>
<input type="text" id="admin-display-name" placeholder="Joshua">
<label>role</label>
<select id="admin-role">
<option value="contributor">contributor</option>
<option value="admin">admin</option>
<option value="service">service</option>
</select>
<label class="checkbox-row"><input type="checkbox" id="admin-user-active" checked> active (can sign in)</label>
<label>password</label>
<input type="password" id="admin-password" placeholder="required for new users; leave blank to keep unchanged" autocomplete="new-password">
<div class="manage-user-actions">
<button class="primary" type="button" onclick="saveManagedUser()">save user</button>
<button type="button" onclick="setManagedUserActive(false)">inactivate</button>
<button type="button" onclick="setManagedUserActive(true)">activate</button>
<button type="button" onclick="deleteManagedUser()">delete</button>
<button type="button" onclick="clearManageUserForm()">clear</button>
</div>
</div>
</div>
</div>
<div id="admin-panel-projects" class="admin-tab-panel" role="tabpanel">
<nav class="admin-subtabs" role="tablist">
<button type="button" class="admin-subtab active" data-projects-subtab="list" onclick="switchProjectsSubTab('list')">manage projects</button>
</nav>
<div class="admin-tab-scroll">
<div id="admin-projects-sub-list" class="admin-projects-subpanel active">
<p class="dialog-hint">Delete a project by typing its name to confirm. This removes all context files, snapshots, and audit references (cascaded).</p>
<div class="admin-users" id="admin-projects-list" style="max-height:none;border:none;overflow:visible;">
<div class="loading">loading projects…</div>
</div>
</div>
</div>
</div>
</div>
<div class="admin-dialog-footer actions">
<button type="button" onclick="hideAdminDialog()">close</button>
</div>
</div>
</div>
<!-- ── Auth dialog ── -->
<div class="dialog-overlay" id="auth-dialog">
<div class="dialog">
<h2>sign in</h2>
<p class="dialog-hint">Sign in with your CTXD user id and password.</p>
<label>user id</label>
<input type="text" id="auth-user-id" placeholder="e.g. joshua" autocomplete="username" autocorrect="off" autocapitalize="off" spellcheck="false" onkeydown="if(event.key==='Enter')submitLogin()">
<label>password</label>
<input type="password" id="auth-password-input" placeholder="password" autocomplete="current-password" onkeydown="if(event.key==='Enter')submitLogin()">
<div class="actions">
<button class="primary" onclick="submitLogin()">sign in</button>
</div>
</div>
</div>
<!-- ── Toast ── -->
<div class="toast" id="toast"></div>
<script>
// ── State ────────────────────────────────────────────────────────
const state = {
projects: [],
currentProject: null,
currentFile: null,
currentFileContent: '',
currentTab: 'context',
currentVersion: 0,
fileVersion: 0,
files: [],
filesByProject: {},
expandedProjects: new Set(),
users: [],
currentUser: null,
searchResults: null,
};
// ── API helpers ──────────────────────────────────────────────────
const API = window.location.origin;
function getSessionToken() {
// Check localStorage first (set by login JS)
let token = localStorage.getItem('ctxd_session_token') || '';
if (!token) {
// Fall back to cookie (set by server on login)
const cookies = document.cookie.split(';');
for (const c of cookies) {
const trimmed = c.trim();
if (trimmed.startsWith('ctxd_session=')) {
token = trimmed.substring('ctxd_session='.length);
if (token) localStorage.setItem('ctxd_session_token', token);
break;
}
}
}
return token;
}
function setSessionToken(token) {
localStorage.setItem('ctxd_session_token', token);
}
function clearSession() {
localStorage.removeItem('ctxd_session_token');
localStorage.removeItem('ctxd_api_key');
state.currentUser = null;
updateAuthChrome();
}
function authHeaders() {
const token = getSessionToken();
return token ? { Authorization: `Bearer ${token}` } : {};
}
function writeActor() {
return (state.currentUser && state.currentUser.user_id) ? state.currentUser.user_id : 'admin';
}
async function api(method, path, body) {
const opts = { method, headers: { ...authHeaders() } };
if (body) {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(body);
}
const res = await fetch(API + path, opts);
if (res.status === 401) {
clearSession();
showAuthDialog();
throw new Error('unauthorized');
}
if (!res.ok) {
const text = await res.text();
throw new Error(`${res.status}: ${text.slice(0, 200)}`);
}
return res.json();
}
// ── Sidebar toggle (mobile) ─────────────────────────────────────
function openSidebar() {
document.getElementById('sidebar').classList.add('open');
document.getElementById('sidebar-overlay').classList.add('active');
}
function closeSidebar() {
document.getElementById('sidebar').classList.remove('open');
document.getElementById('sidebar-overlay').classList.remove('active');
}
document.getElementById('hamburger').addEventListener('click', openSidebar);
document.getElementById('sidebar-overlay').addEventListener('click', closeSidebar);
// ── Toast ────────────────────────────────────────────────────────
let toastTimer;
// ── Auth dialog ──────────────────────────────────────────────────
function showAuthDialog() {
document.getElementById('auth-dialog').classList.add('active');
setTimeout(() => document.getElementById('auth-user-id').focus(), 100);
}
function hideAuthDialog() {
document.getElementById('auth-dialog').classList.remove('active');
}
function updateAuthChrome() {
const isAdmin = state.currentUser && state.currentUser.role === 'admin';
document.getElementById('admin-btn').style.display = isAdmin ? '' : 'none';
document.getElementById('logout-btn').style.display = state.currentUser ? '' : 'none';
const status = document.getElementById('status-text');
if (state.currentUser) {
status.textContent = 'signed in · ' + (state.currentUser.display_name || state.currentUser.user_id);
}
}
async function submitLogin() {
const user_id = document.getElementById('auth-user-id').value.trim();
const password = document.getElementById('auth-password-input').value;
if (!user_id || !password) return;
try {
const res = await fetch(API + '/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id, password }),
});
if (!res.ok) {
let detail = '';
try { detail = (await res.json()).error || ''; } catch (_) {}
if (res.status === 401) {
showToast(detail === 'invalid credentials' ? 'invalid user id or password' : ('login failed: ' + (detail || res.status)), 'error');
} else if (res.status === 403 && detail === 'account inactive') {
showToast('account inactive — contact an admin', 'error');
} else {
showToast(`login failed (${res.status}): ${detail || res.statusText}`, 'error');
}
return;
}
const data = await res.json();
setSessionToken(data.token);
state.currentUser = data;
hideAuthDialog();
updateAuthChrome();
showToast('signed in', 'success');
await loadProjects();
} catch (e) {
showToast('login failed: ' + e.message, 'error');
}
}
async function logout() {
try { await api('POST', '/auth/logout'); } catch (e) { /* ignore */ }
clearSession();
document.getElementById('project-list').innerHTML = '';
document.getElementById('content-area').innerHTML = '<div class="loading">sign in to continue</div>';
showAuthDialog();
}
function showToast(msg, type) {
const el = document.getElementById('toast');
el.textContent = msg;
el.className = 'toast show' + (type ? ' ' + type : '');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => el.classList.remove('show'), 3000);
}
// ── Status ──────────────────────────────────────────────────────
async function checkStatus() {
try {
const data = await fetch(API + '/status').then(r => r.json());
if (!state.currentUser) {
document.getElementById('status-text').textContent = 'connected · ' + data.db.split('/').pop();
}
} catch (e) {
document.getElementById('status-text').textContent = 'disconnected';
}
}
// ── Admin ───────────────────────────────────────────────────────
function switchAdminTab(tab) {
document.querySelectorAll('.admin-tab').forEach(t => {
const on = t.dataset.adminTab === tab;
t.classList.toggle('active', on);
t.setAttribute('aria-selected', on ? 'true' : 'false');
});
document.querySelectorAll('.admin-tab-panel').forEach(p => {
p.classList.toggle('active', p.id === `admin-panel-${tab}`);
});
}
function switchUsersSubTab(sub) {
const panel = document.getElementById('admin-panel-users');
panel.querySelectorAll('[data-users-subtab]').forEach(t => {
t.classList.toggle('active', t.dataset.usersSubtab === sub);
});
document.getElementById('admin-users-sub-list').classList.toggle('active', sub === 'list');
document.getElementById('admin-users-sub-manage').classList.toggle('active', sub === 'manage');
}
function switchOAuthSubTab(sub) {
const panel = document.getElementById('admin-panel-oauth');
panel.querySelectorAll('[data-oauth-subtab]').forEach(t => {
t.classList.toggle('active', t.dataset.oauthSubtab === sub);
});
document.getElementById('admin-oauth-sub-list').classList.toggle('active', sub === 'list');
document.getElementById('admin-oauth-sub-create').classList.toggle('active', sub === 'create');
}
function switchProjectsSubTab(sub) {
const panel = document.getElementById('admin-panel-projects');
panel.querySelectorAll('[data-projects-subtab]').forEach(t => {
t.classList.toggle('active', t.dataset.projectsSubtab === sub);
});
document.getElementById('admin-projects-sub-list').classList.toggle('active', sub === 'list');
}
function clearManageUserForm() {
document.getElementById('admin-manage-select').value = '';
document.getElementById('admin-user-id').value = '';
document.getElementById('admin-user-id').readOnly = false;
document.getElementById('admin-display-name').value = '';
document.getElementById('admin-role').value = 'contributor';
document.getElementById('admin-user-active').checked = true;
document.getElementById('admin-password').value = '';
}
function fillManageUserForm(u) {
document.getElementById('admin-user-id').value = u.user_id || '';
document.getElementById('admin-user-id').readOnly = true;
document.getElementById('admin-display-name').value = u.display_name || '';
document.getElementById('admin-role').value = u.role || 'contributor';
document.getElementById('admin-user-active').checked = u.active !== false;
document.getElementById('admin-password').value = '';
}
function onManageUserSelect() {
const uid = document.getElementById('admin-manage-select').value;
if (!uid) {
clearManageUserForm();
return;
}
const u = state.users.find(x => x.user_id === uid);
if (u) fillManageUserForm(u);
}
function syncManageUserSelect() {
const sel = document.getElementById('admin-manage-select');
const cur = sel.value;
sel.innerHTML = '<option value="">— new user —</option>' +
state.users.map(u => `<option value="${escapeHtml(u.user_id)}">${escapeHtml(u.user_id)} · ${escapeHtml(u.display_name || '')}</option>`).join('');
if (cur && state.users.some(u => u.user_id === cur)) sel.value = cur;
}
async function showAdminDialog() {
document.getElementById('admin-dialog').classList.add('active');
clearManageUserForm();
document.getElementById('admin-oauth-result').style.display = 'none';
document.getElementById('admin-oauth-result').textContent = '';
switchAdminTab('oauth');
switchOAuthSubTab('list');
switchUsersSubTab('list');
switchProjectsSubTab('list');
await Promise.all([loadUsers(), loadOAuthClients(), loadAdminProjects()]);
}
function hideAdminDialog() {
document.getElementById('admin-dialog').classList.remove('active');
}
async function loadUsers() {
const el = document.getElementById('admin-users-list');
el.innerHTML = '<div class="loading">loading users…</div>';
try {
state.users = await api('GET', '/users');
renderUserList();
syncManageUserSelect();
} catch (e) {
if (e.message !== 'unauthorized') {
el.innerHTML = `<div class="empty-state">failed to load users: ${escapeHtml(e.message)}</div>`;
}
}
}
function renderUserList() {
const el = document.getElementById('admin-users-list');
if (!state.users.length) {
el.innerHTML = '<div class="empty-state">no users</div>';
return;
}
el.innerHTML = `<table class="user-list-table"><thead><tr><th>user id</th><th>name</th><th>role</th><th>state</th></tr></thead><tbody>${
state.users.map(u => {
const active = u.active !== false;
return `<tr>
<td class="user-id">${escapeHtml(u.user_id || '')}</td>
<td>${escapeHtml(u.display_name || '')}</td>
<td>${escapeHtml(u.role || '')}</td>
<td class="${active ? 'user-state-active' : 'user-state-inactive'}">${active ? 'active' : 'inactive'}</td>
</tr>`;
}).join('')
}</tbody></table>`;
}
async function saveManagedUser() {
const selectUid = document.getElementById('admin-manage-select').value;
const userId = document.getElementById('admin-user-id').value.trim();
const displayName = document.getElementById('admin-display-name').value.trim();
const role = document.getElementById('admin-role').value;
const active = document.getElementById('admin-user-active').checked;
const password = document.getElementById('admin-password').value;
if (!userId || !displayName) {
showToast('user id and display name required', 'error');
return;
}
const isEdit = Boolean(selectUid);
try {
if (isEdit) {
const body = { display_name: displayName, role, active };
if (password.trim()) body.password = password.trim();
await api('PATCH', `/users/${encodeURIComponent(userId)}`, body);
showToast(`user updated · ${userId}`, 'success');
} else {
if (!password.trim()) {
showToast('password required for new users', 'error');
return;
}
await api('POST', '/users', { user_id: userId, display_name: displayName, role, password: password.trim() });
if (!active) await api('PATCH', `/users/${encodeURIComponent(userId)}`, { active: false });
showToast(`user created · ${userId}`, 'success');
clearManageUserForm();
}
await loadUsers();
if (isEdit) {
document.getElementById('admin-manage-select').value = userId;
const u = state.users.find(x => x.user_id === userId);
if (u) fillManageUserForm(u);
}
} catch (e) {
if (e.message !== 'unauthorized') showToast('save user failed: ' + e.message, 'error');
}
}
async function setManagedUserActive(active) {
const userId = document.getElementById('admin-manage-select').value || document.getElementById('admin-user-id').value.trim();
if (!userId) {
showToast('select a user first', 'error');
return;
}
try {
await api('PATCH', `/users/${encodeURIComponent(userId)}`, { active });
document.getElementById('admin-user-active').checked = active;
showToast(active ? `activated · ${userId}` : `inactivated · ${userId}`, 'success');
await loadUsers();
document.getElementById('admin-manage-select').value = userId;
const u = state.users.find(x => x.user_id === userId);
if (u) fillManageUserForm(u);
} catch (e) {
if (e.message !== 'unauthorized') showToast('update failed: ' + e.message, 'error');
}
}
async function deleteManagedUser() {
const userId = document.getElementById('admin-manage-select').value || document.getElementById('admin-user-id').value.trim();
if (!userId) {
showToast('select a user first', 'error');
return;
}
if (!confirm(`Delete user "${userId}"? If delete fails, inactivate instead.`)) return;
try {
await api('DELETE', `/users/${encodeURIComponent(userId)}`);
showToast(`user deleted · ${userId}`, 'success');
clearManageUserForm();
await loadUsers();
} catch (e) {
if (e.message !== 'unauthorized') showToast('delete failed: ' + e.message, 'error');
}
}
async function loadOAuthClients() {
const el = document.getElementById('admin-oauth-list');
el.innerHTML = '<div class="loading">loading oauth clients…</div>';
try {
const clients = await api('GET', '/oauth/clients');
if (!clients.length) {
el.innerHTML = '<div class="empty-state">no oauth clients yet</div>';
return;
}
el.innerHTML = clients.map(c => {
const cid = c.client_id || '';
return `
<div class="admin-oauth-row">
<div class="oauth-client-meta">
<div class="oauth-client-id">${escapeHtml(cid)}</div>
<div class="oauth-client-name">${escapeHtml(c.client_name || '')}</div>
<div class="oauth-client-uri">${escapeHtml((c.redirect_uris || []).join(', '))}</div>
</div>
<button type="button" class="oauth-revoke" data-client-id="${escapeHtml(cid)}" onclick="revokeOAuthClient(this.dataset.clientId)">revoke</button>
</div>`;
}).join('');
} catch (e) {
if (e.message !== 'unauthorized') {
el.innerHTML = `<div class="empty-state">${escapeHtml(e.message)}</div>`;
}
}
}
async function revokeOAuthClient(clientId) {
if (!clientId) return;
if (!confirm(`Revoke OAuth client "${clientId}"?\n\nPending auth codes and access/refresh tokens for this client are invalidated.`)) return;
try {
await api('DELETE', `/oauth/clients/${encodeURIComponent(clientId)}`);
showToast(`oauth client revoked · ${clientId}`, 'success');
await loadOAuthClients();
} catch (e) {
if (e.message !== 'unauthorized') showToast('revoke failed: ' + e.message, 'error');
}
}
async function createOAuthClient() {
const name = document.getElementById('admin-oauth-name').value.trim() || 'MCP connector';
const redirect = document.getElementById('admin-oauth-redirect').value.trim();
if (!redirect) {
showToast('redirect uri required', 'error');
return;
}
try {
const client = await api('POST', '/oauth/clients', { client_name: name, redirect_uris: [redirect] });
const pre = document.getElementById('admin-oauth-result');
pre.style.display = 'block';
pre.textContent = [
'Save these now — client_secret is not shown again.',
'',
`connector_url: ${client.connector_url || ''}`,
`authorization_server: ${client.authorization_server || ''}`,
`client_id: ${client.client_id || ''}`,
`client_secret: ${client.client_secret || ''}`,
`redirect_uris: ${JSON.stringify(client.redirect_uris || [])}`,
].join('\n');
showToast('oauth client created — copy secret from admin panel', 'success');
switchOAuthSubTab('create');
await loadOAuthClients();
} catch (e) {
if (e.message !== 'unauthorized') showToast('oauth client failed: ' + e.message, 'error');
}
}
// ── Admin Projects ──────────────────────────────────────────────
async function loadAdminProjects() {
const el = document.getElementById('admin-projects-list');
el.innerHTML = '<div class="loading">loading projects…</div>';
try {
const projects = await api('GET', '/projects');
if (!projects.length) {
el.innerHTML = '<div class="empty-state">no projects</div>';
return;
}
el.innerHTML = projects.map(p => {
const pid = p.project_id || '';
const name = p.display_name || '';
const ver = p.shared_version || 0;
return `
<div class="admin-oauth-row">
<div class="oauth-client-meta">
<div class="oauth-client-id">${escapeHtml(pid)}</div>
<div class="oauth-client-name">${escapeHtml(name)} · v${ver}</div>
</div>
<button type="button" class="oauth-revoke" data-project-id="${escapeHtml(pid)}" onclick="promptDeleteProject(this.dataset.projectId)">remove</button>
</div>`;
}).join('');
} catch (e) {
if (e.message !== 'unauthorized') {
el.innerHTML = `<div class="empty-state">failed to load: ${escapeHtml(e.message)}</div>`;
}
}
}
function promptDeleteProject(projectId) {
if (!projectId) return;
// Build a typed-confirm modal
const existing = document.getElementById('project-delete-modal');
if (existing) existing.remove();
const modal = document.createElement('div');
modal.id = 'project-delete-modal';
modal.className = 'dialog-overlay active';
modal.innerHTML = `
<div class="dialog" style="max-width:400px">
<h2 style="color:var(--danger)">delete project</h2>
<p class="dialog-hint">This will permanently delete <strong style="color:var(--accent)">${escapeHtml(projectId)}</strong> and all its context files, snapshots, and audit references.</p>
<p class="dialog-hint" style="margin-top:0.5rem">Type the project name to confirm:</p>
<input type="text" id="project-delete-confirm-input" placeholder="${escapeHtml(projectId)}" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" onkeydown="if(event.key==='Enter')confirmDeleteProject('${escapeHtml(projectId).replace(/'/g,"\\'")}')">
<div class="actions" style="margin-top:1rem">
<button onclick="document.getElementById('project-delete-modal').remove()">cancel</button>
<button class="primary" id="project-delete-confirm-btn" style="background:var(--danger);border-color:var(--danger);color:var(--paper)" onclick="confirmDeleteProject('${escapeHtml(projectId).replace(/'/g,"\\'")}')">delete project</button>
</div>
</div>`;
document.body.appendChild(modal);
setTimeout(() => document.getElementById('project-delete-confirm-input')?.focus(), 50);
// Disable the confirm button until the typed name matches
const input = modal.querySelector('#project-delete-confirm-input');
const btn = modal.querySelector('#project-delete-confirm-btn');
btn.disabled = true;
btn.style.opacity = '0.4';
input.addEventListener('input', () => {
const match = input.value.trim() === projectId;
btn.disabled = !match;
btn.style.opacity = match ? '1' : '0.4';
});
}
async function confirmDeleteProject(projectId) {
const input = document.getElementById('project-delete-confirm-input');
if (!input || input.value.trim() !== projectId) {
showToast('project name does not match', 'error');
return;
}
try {
await api('DELETE', `/projects/${encodeURIComponent(projectId)}`);
showToast(`project deleted · ${projectId}`, 'success');
document.getElementById('project-delete-modal')?.remove();
await loadAdminProjects();
await loadProjects();
} catch (e) {
if (e.message !== 'unauthorized') showToast('delete failed: ' + e.message, 'error');
}
}
// ── Projects ─────────────────────────────────────────────────────
async function loadProjects() {
try {
state.projects = await api('GET', '/projects');
renderProjectList();
} catch (e) {
if (e.message !== 'unauthorized') {
showToast('Failed to load projects: ' + e.message, 'error');
}
}
}
function renderProjectList() {
const el = document.getElementById('project-list');
el.innerHTML = state.projects.map(p => {
const pid = p.project_id;
const expanded = state.expandedProjects.has(pid);
const files = state.filesByProject[pid] || [];
const isActiveProject = state.currentProject === pid;
const fileListHtml = expanded ? `
<div class="project-files">
${files.length ? files.map(f => `
<div class="project-file-item${isActiveProject && state.currentFile === f.file_path ? ' active' : ''}"
onclick="selectSidebarFile('${pid}', '${f.file_path}')">
<span class="project-file-name">${f.file_path.replace(/\.MD$/i, '')}</span>
<span class="project-file-version">v${f.version}</span>
</div>
`).join('') : '<div class="project-files-loading">loading files…</div>'}
</div>
` : '';
return `
<div class="project-block">
<div class="project-item${isActiveProject ? ' active' : ''}"
onclick="toggleProject('${pid}')">
<span class="chevron">${expanded ? '▾' : '▸'}</span>
<span class="name">${p.display_name || pid}</span>
<span class="version">v${p.shared_version}</span>
</div>
${fileListHtml}
</div>
`;
}).join('');
}
async function ensureProjectFiles(pid, force = false) {
if (!force && state.filesByProject[pid]) return state.filesByProject[pid];
let files = await api('GET', `/projects/${pid}/files`);
if (files.length === 0) {
await api('POST', `/projects/${pid}/migrate-files`);
files = await api('GET', `/projects/${pid}/files`);
}
state.filesByProject[pid] = files;
if (state.currentProject === pid) state.files = files;
return files;
}
// ── Select project ──────────────────────────────────────────────
async function toggleProject(pid) {
const wasExpanded = state.expandedProjects.has(pid);
if (wasExpanded) {
state.expandedProjects.delete(pid);
renderProjectList();
return;
}
await openProject(pid, { closeAfterSelect: false });
}
async function selectProject(pid) {
await openProject(pid, { closeAfterSelect: true });
}
async function openProject(pid, { closeAfterSelect = false } = {}) {
state.currentProject = pid;
state.searchResults = null;
state.currentFile = null;
state.fileVersion = 0;
state.files = [];
state.expandedProjects.add(pid);
document.getElementById('search-input').value = '';
switchTab('context', { render: false });
renderProjectList();
const area = document.getElementById('content-area');
area.innerHTML = '<div class="loading">loading…</div>';
try {
const files = await ensureProjectFiles(pid);
renderProjectList();
if (files.length > 0) {
await selectProjectFile(pid, files[0].file_path, { closeAfterSelect });
} else {
area.innerHTML = '<div class="empty-state">no files — create one to get started</div>';
}
} catch (e) {
if (e.message !== 'unauthorized') {
area.innerHTML = `<div class="empty-state">error: ${e.message}</div>`;
}
}
}
// ── File management ──────────────────────────────────────────────
async function loadProjectFiles(pid) {
await openProject(pid, { closeAfterSelect: false });
}
async function selectSidebarFile(pid, filePath) {
await selectProjectFile(pid, filePath, { closeAfterSelect: true });
}
async function selectFile(filePath) {
await selectProjectFile(state.currentProject, filePath, { closeAfterSelect: false });
}
async function selectProjectFile(pid, filePath, { closeAfterSelect = false } = {}) {
state.currentProject = pid;
state.currentFile = filePath;
state.fileVersion = 0;
state.searchResults = null;
state.expandedProjects.add(pid);
document.getElementById('search-input').value = '';
switchTab('context', { render: false });
try {
const files = await ensureProjectFiles(pid);
state.files = files;
renderProjectList();
renderFileTabs();
await loadFile(pid, filePath);
if (closeAfterSelect) closeSidebar();
} catch (e) {
if (e.message !== 'unauthorized') {
document.getElementById('content-area').innerHTML = `<div class="empty-state">error: ${e.message}</div>`;
}
}
}
function renderFileTabs() {
const tabsEl = document.getElementById('file-tabs');
if (!tabsEl || !state.files.length) return;
tabsEl.innerHTML = state.files.map(f => `
<button class="file-tab${state.currentFile === f.file_path ? ' active' : ''}"
onclick="selectFile('${f.file_path}')">
${f.file_path.replace('.MD', '').replace('.md', '')}
<span class="file-tab-version">v${f.version}</span>
</button>
`).join('') + `
<button class="file-tab file-tab-add" onclick="showNewFileDialog()" title="add file">+</button>
`;
}
async function loadFile(pid, filePath) {
const area = document.getElementById('content-area');
area.innerHTML = '<div class="loading">loading…</div>';
try {
const data = await api('GET', `/projects/${pid}/files/${encodeURIComponent(filePath)}`);
state.fileVersion = data.version;
state.currentFileContent = data.content;
renderContext(data.content, data.version, filePath);
} catch (e) {
if (e.message !== 'unauthorized') {
area.innerHTML = `<div class="empty-state">error: ${e.message}</div>`;
}
}
}
// ── Markdown custom renderer ─────────────────────────────────────
// The metadata header (TYPE: PROJECT CONTEXT ...) should render in gold.
// Strip it before markdown rendering, render the rest, then re-insert
// it styled as a <div class=ctxd-header>.
const ctxdHeaderRegex = /^(TYPE: PROJECT CONTEXT[\s\S]*?(?:\n--\n___|\n---))/;
function extractHeader(content) {
const m = content.match(ctxdHeaderRegex);
return m ? { header: m[1], rest: content.slice(m[0].length) } : null;
}
// ── Render context ──────────────────────────────────────────────
function renderContext(content, version, filePath) {
const area = document.getElementById('content-area');
const displayName = filePath ? filePath.replace(/\.MD$/i, '') : 'context';
// Extract the metadata header so markdown doesn't mangle it
const extracted = extractHeader(content);
let headerHtml = '';
let body = content;
if (extracted) {
headerHtml = `<div class="ctxd-header">${escapeHtml(extracted.header)}</div>`;
body = extracted.rest;
}
const html = marked.parse(body, { breaks: true, gfm: true });
area.innerHTML = `
<div class="file-tabs" id="file-tabs"></div>
<div class="editor-pane active">
<div class="editor-toolbar">
<span class="meta">${state.currentProject} / ${displayName} · <strong>v${version}</strong></span>
<span class="spacer"></span>
<button onclick="toggleEdit()">edit</button>
<button onclick="showImportDialog()">import</button>
<button onclick="syncProject()">sync</button>
</div>
<div class="newspaper" id="context-view">${headerHtml}${html}</div>
</div>
<div class="editor-pane" id="editor-pane">
<div class="editor-toolbar">
<span class="meta">editing · ${displayName} · <strong>v${version}</strong></span>
<span class="spacer"></span>
<button onclick="toggleEdit()">cancel</button>
<button class="primary" onclick="saveFileContent()">save</button>
</div>
<div class="editor-split">
<textarea id="editor-textarea">${escapeHtml(content)}</textarea>
<div class="editor-preview" id="editor-preview"></div>
</div>
</div>
`;
// Render file tabs
renderFileTabs();
// Highlight code blocks
document.querySelectorAll('#context-view pre code').forEach(block => {
hljs.highlightElement(block);
});
// Live preview
const ta = document.getElementById('editor-textarea');
if (ta) {
ta.addEventListener('input', updatePreview);
}
}
function updatePreview() {
const ta = document.getElementById('editor-textarea');
const preview = document.getElementById('editor-preview');
if (ta && preview) {
preview.innerHTML = marked.parse(ta.value, { breaks: true, gfm: true });
}
}
let editing = false;
function toggleEdit() {
editing = !editing;
document.querySelectorAll('.editor-pane').forEach(el => el.classList.toggle('active'));
if (editing) updatePreview();
}
async function saveFileContent() {
const ta = document.getElementById('editor-textarea');
if (!ta) return;
const content = ta.value;
try {
const result = await api('PUT', `/projects/${state.currentProject}/files/${encodeURIComponent(state.currentFile)}`, {
content,
updated_by: writeActor(),
base_version: state.fileVersion,
});
if (result.ok) {
showToast(`saved · v${result.new_version}`, 'success');
state.fileVersion = result.new_version;
editing = false;
// Reload the file to show the updated content with fresh header
await loadFile(state.currentProject, state.currentFile);
// Update file version in tabs/sidebar cache
const fileEntry = state.files.find(f => f.file_path === state.currentFile);
if (fileEntry) fileEntry.version = result.new_version;
if (state.filesByProject[state.currentProject]) {
const cachedEntry = state.filesByProject[state.currentProject].find(f => f.file_path === state.currentFile);
if (cachedEntry) cachedEntry.version = result.new_version;
}
renderFileTabs();
renderProjectList();
} else if (result.error === 'conflict') {
showToast(`conflict: base v${state.fileVersion} but current is v${result.current_version} — re-read and merge`, 'error');
} else {
showToast('save failed: ' + (result.error || 'unknown'), 'error');
}
} catch (e) {
showToast('save error: ' + e.message, 'error');
}
}
// ── New file dialog ─────────────────────────────────────────────
function showNewFileDialog() {
const name = prompt('File name (e.g. ARCHITECTURE.md):');
if (!name) return;
// Normalize
let fname = name.toUpperCase();
if (!fname.endsWith('.MD')) fname += '.MD';
createNewFile(fname);
}
async function createNewFile(filePath) {
try {
const result = await api('POST', `/projects/${state.currentProject}/files`, {
file_path: filePath,
content: '',
updated_by: writeActor(),
});
if (result.ok) {
showToast(`created ${filePath}`, 'success');
// Reload file list and select the new file
const files = await ensureProjectFiles(state.currentProject, true);
state.files = files;
renderProjectList();
selectFile(filePath);
} else {
showToast('create failed: ' + (result.error || 'unknown'), 'error');
}
} catch (e) {
showToast('create failed: ' + e.message, 'error');
}
}
async function syncProject() {
try {
const result = await api('POST', `/projects/${state.currentProject}/sync`);
if (result.ok) {
showToast(`synced to ${result.path}`, 'success');
} else {
showToast('sync: ' + (result.error || 'failed'), 'error');
}
} catch (e) {
showToast('sync error: ' + e.message, 'error');
}
}
// ── Search ──────────────────────────────────────────────────────
async function doSearch() {
const q = document.getElementById('search-input').value.trim();
if (!q) return;
const area = document.getElementById('content-area');
area.innerHTML = '<div class="loading">searching…</div>';
try {
const results = await api('GET', `/search?q=${encodeURIComponent(q)}`);
state.searchResults = results;
renderSearchResults(results, q);
} catch (e) {
area.innerHTML = `<div class="empty-state">search error: ${e.message}</div>`;
}
}
function renderSearchResults(results, query) {
const area = document.getElementById('content-area');
if (results.length === 0) {
area.innerHTML = `<div class="empty-state">no results for "${query}"</div>`;
return;
}
area.innerHTML = `
<div style="margin-bottom:0.75rem;font-size:0.7rem;color:var(--ink-dim);text-transform:uppercase;letter-spacing:0.08em;">
${results.length} result${results.length !== 1 ? 's' : ''} for "${query}"
</div>
${results.map(r => {
const snippet = (r.content || '').slice(0, 300);
return `
<div class="search-result-item" onclick="selectProjectFile('${r.project_id}', '${r.file_path || 'CONTEXT.MD'}')">
<div class="src">${r.source_type || '?'}</div>
<div class="path">${r.project_id}/${r.file_path || ''}</div>
<div class="snippet">${escapeHtml(snippet)}</div>
</div>
`;
}).join('')}
`;
}
// ── Tab switching ────────────────────────────────────────────────
function switchTab(tab, options = {}) {
const shouldRender = options.render !== false;
state.currentTab = tab;
document.querySelectorAll('.sidebar-footer button').forEach(b => {
b.classList.toggle('active-tab', b.dataset.tab === tab);
});
if (!shouldRender) return;
const area = document.getElementById('content-area');
if (tab === 'context') {
if (state.currentProject && state.currentFile) {
loadFile(state.currentProject, state.currentFile);
} else if (state.currentProject) {
loadProjectFiles(state.currentProject);
} else {
area.innerHTML = '<div class="loading">select a project from the sidebar</div>';
}
} else if (tab === 'audit') {
loadAudit();
} else if (tab === 'snapshots') {
loadSnapshots();
}
}
async function loadAudit() {
const area = document.getElementById('content-area');
area.innerHTML = '<div class="loading">loading audit log…</div>';
try {
const entries = await api('GET', '/audit?limit=50');
area.innerHTML = `
<h1>audit log</h1>
<table class="audit-table">
<thead><tr>
<th>time</th><th>user</th><th>agent</th><th>op</th><th>project</th><th>summary</th>
</tr></thead>
<tbody>
${entries.map(e => `
<tr>
<td style="white-space:nowrap;font-size:0.7rem">${(e.created_at || '').slice(0,19)}</td>
<td>${e.user_id || ''}</td>
<td style="font-size:0.7rem">${e.agent_id || ''}</td>
<td><span style="color:var(--accent)">${e.operation || ''}</span></td>
<td style="font-size:0.7rem">${e.project_id || ''}</td>
<td>${escapeHtml((e.summary || '').slice(0, 80))}</td>
</tr>
`).join('')}
</tbody>
</table>
`;
} catch (e) {
area.innerHTML = `<div class="empty-state">error: ${e.message}</div>`;
}
}
async function loadSnapshots() {
const area = document.getElementById('content-area');
if (!state.currentProject) {
area.innerHTML = '<div class="loading">select a project to view snapshots</div>';
return;
}
area.innerHTML = '<div class="loading">loading snapshots…</div>';
try {
const snaps = await api('GET', `/projects/${state.currentProject}/snapshots`);
area.innerHTML = `
<h1>snapshots · ${state.currentProject}</h1>
${snaps.length === 0 ? '<div class="empty-state">no snapshots yet</div>' : `
<table class="audit-table">
<thead><tr><th>time</th><th>from</th><th>to</th><th>size</th><th>hash</th></tr></thead>
<tbody>
${snaps.map(s => `
<tr>
<td style="white-space:nowrap;font-size:0.7rem">${(s.created_at || '').slice(0,19)}</td>
<td>v${s.version_from}</td>
<td>v${s.version_to}</td>
<td>${s.size_bytes || 0}b</td>
<td style="font-size:0.65rem;color:var(--ink-dim)">${(s.content_hash || '').slice(0,12)}</td>
</tr>
`).join('')}
</tbody>
</table>
`}
`;
} catch (e) {
area.innerHTML = `<div class="empty-state">error: ${e.message}</div>`;
}
}
// ── New project dialog ──────────────────────────────────────────
function showNewProjectDialog() {
document.getElementById('new-project-dialog').classList.add('active');
document.getElementById('new-pid').value = '';
document.getElementById('new-name').value = '';
document.getElementById('new-desc').value = '';
document.getElementById('import-file-name').textContent = 'no file selected';
document.getElementById('import-file').value = '';
setTimeout(() => document.getElementById('new-pid').focus(), 100);
}
function hideNewProjectDialog() {
document.getElementById('new-project-dialog').classList.remove('active');
}
// ── File upload helpers ─────────────────────────────────────────
let pendingImportFile = null;
document.getElementById('import-file').addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) {
document.getElementById('import-file-name').textContent = file.name;
pendingImportFile = file;
}
});
document.getElementById('import-file-existing').addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) {
document.getElementById('import-file-name-existing').textContent = file.name;
}
});
function readFileAsText(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(new Error('Failed to read file'));
reader.readAsText(file);
});
}
async function createProject() {
const pid = document.getElementById('new-pid').value.trim();
const name = document.getElementById('new-name').value.trim() || pid;
const desc = document.getElementById('new-desc').value.trim();
if (!pid) { showToast('project id is required', 'error'); return; }
try {
await api('POST', '/projects', { project_id: pid, display_name: name, description: desc });
// If a file was selected, import it as the project context
if (pendingImportFile) {
const content = await readFileAsText(pendingImportFile);
await api('POST', `/projects/${pid}/import`, {
content,
updated_by: writeActor(),
base_version: 0,
});
showToast(`created ${pid} · imported ${pendingImportFile.name}`, 'success');
pendingImportFile = null;
} else {
showToast(`created ${pid}`, 'success');
}
hideNewProjectDialog();
await loadProjects();
selectProject(pid);
} catch (e) {
showToast('create failed: ' + e.message, 'error');
}
}
// ── Import dialog (existing project) ────────────────────────────
function showImportDialog() {
document.getElementById('import-dialog').classList.add('active');
document.getElementById('import-file-name-existing').textContent = 'no file selected';
document.getElementById('import-file-existing').value = '';
}
function hideImportDialog() {
document.getElementById('import-dialog').classList.remove('active');
}
async function importToExisting() {
const fileInput = document.getElementById('import-file-existing');
const file = fileInput.files[0];
if (!file) { showToast('select a file first', 'error'); return; }
if (!state.currentProject) { showToast('no project selected', 'error'); return; }
try {
const content = await readFileAsText(file);
const result = await api('POST', `/projects/${state.currentProject}/import`, {
content,
updated_by: writeActor(),
base_version: state.currentVersion,
});
if (result.ok) {
showToast(`imported ${file.name} · v${result.new_version}`, 'success');
hideImportDialog();
await ensureProjectFiles(state.currentProject, true);
await selectFile(state.currentFile || 'CONTEXT.MD');
} else if (result.error === 'conflict') {
showToast('version conflict — reload and try again', 'error');
} else {
showToast('import failed: ' + (result.error || 'unknown'), 'error');
}
} catch (e) {
showToast('import failed: ' + e.message, 'error');
}
}
// ── Utilities ───────────────────────────────────────────────────
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// ── Init ─────────────────────────────────────────────────────────
async function bootstrap() {
await checkStatus();
if (!getSessionToken()) {
showAuthDialog();
return;
}
try {
state.currentUser = await api('GET', '/auth/me');
updateAuthChrome();
await loadProjects();
} catch (e) {
clearSession();
showAuthDialog();
}
}
bootstrap();
</script>
</body>
</html>