fc1a2f5103
- 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
2483 lines
84 KiB
HTML
2483 lines
84 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||
<title>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>
|