1674 lines
54 KiB
HTML
1674 lines
54 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>Context Dossier</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;
|
|||
|
|
}
|
|||
|
|
.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 {
|
|||
|
|
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 { border-color: var(--accent); }
|
|||
|
|
.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>📋 Context Dossier <span>· project context</span></h1>
|
|||
|
|
<div class="edition">
|
|||
|
|
<span class="dot"></span>
|
|||
|
|
<span id="status-text">connecting…</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>
|
|||
|
|
|
|||
|
|
<!-- ── Auth dialog ── -->
|
|||
|
|
<div class="dialog-overlay" id="auth-dialog">
|
|||
|
|
<div class="dialog">
|
|||
|
|
<h2>authentication required</h2>
|
|||
|
|
<p class="dialog-hint">Enter the API key to access Context Dossier.</p>
|
|||
|
|
<input type="password" id="auth-key-input" placeholder="API key" autocomplete="off" onkeydown="if(event.key==='Enter')submitAuthKey()">
|
|||
|
|
<div class="actions">
|
|||
|
|
<button class="primary" onclick="submitAuthKey()">connect</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(),
|
|||
|
|
searchResults: null,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ── API helpers ──────────────────────────────────────────────────
|
|||
|
|
const API = window.location.origin;
|
|||
|
|
|
|||
|
|
// Auth — API key stored in localStorage
|
|||
|
|
function getApiKey() {
|
|||
|
|
return localStorage.getItem('ctxd_api_key') || '';
|
|||
|
|
}
|
|||
|
|
function setApiKey(key) {
|
|||
|
|
localStorage.setItem('ctxd_api_key', key);
|
|||
|
|
}
|
|||
|
|
function clearApiKey() {
|
|||
|
|
localStorage.removeItem('ctxd_api_key');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function api(method, path, body) {
|
|||
|
|
const opts = { method, headers: {} };
|
|||
|
|
const key = getApiKey();
|
|||
|
|
if (key) opts.headers['Authorization'] = `Bearer ${key}`;
|
|||
|
|
if (body) {
|
|||
|
|
opts.headers['Content-Type'] = 'application/json';
|
|||
|
|
opts.body = JSON.stringify(body);
|
|||
|
|
}
|
|||
|
|
const res = await fetch(API + path, opts);
|
|||
|
|
if (res.status === 401) {
|
|||
|
|
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-key-input').focus(), 100);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function hideAuthDialog() {
|
|||
|
|
document.getElementById('auth-dialog').classList.remove('active');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function submitAuthKey() {
|
|||
|
|
const key = document.getElementById('auth-key-input').value.trim();
|
|||
|
|
if (!key) return;
|
|||
|
|
setApiKey(key);
|
|||
|
|
// Test the key by trying to list projects
|
|||
|
|
try {
|
|||
|
|
await api('GET', '/projects');
|
|||
|
|
hideAuthDialog();
|
|||
|
|
showToast('authenticated', 'success');
|
|||
|
|
await loadProjects();
|
|||
|
|
} catch (e) {
|
|||
|
|
clearApiKey();
|
|||
|
|
showToast('invalid API key', 'error');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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 api('GET', '/status');
|
|||
|
|
document.getElementById('status-text').textContent = 'connected · ' + data.db.split('/').pop();
|
|||
|
|
} catch (e) {
|
|||
|
|
document.getElementById('status-text').textContent = 'disconnected';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── 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: 'admin',
|
|||
|
|
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: 'admin',
|
|||
|
|
});
|
|||
|
|
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: 'admin',
|
|||
|
|
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: 'admin',
|
|||
|
|
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 ─────────────────────────────────────────────────────────
|
|||
|
|
checkStatus();
|
|||
|
|
loadProjects();
|
|||
|
|
</script>
|
|||
|
|
</body>
|
|||
|
|
</html>
|