Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.1/marked.min.js"
integrity="sha512-pSeTnZAQF/RHxb0ysMoYQI/BRZsa5XuklcrgFfU3YZIdnD3LvkkqzrIeHxzFi6gKtI8Cpq2DEWdZjMTcNVhUYA=="
crossorigin="anonymous"></script>
<!-- DOMPurify sanitizes marked.parse() output before innerHTML assignment (issue #295).
SRI hash verified against cdnjs SRI API:
curl https://api.cdnjs.com/libraries/dompurify/3.2.7?fields=sri -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.7/purify.min.js"
integrity="sha512-78KH17QLT5e55GJqP76vutp1D2iAoy06WcYBXB6iBCsmO6wWzx0Qdg8EDpm8mKXv68BcvHOyeeP4wxAL0twJGQ=="
crossorigin="anonymous"></script>
</head>
<body>
<!-- Navbar -->
Expand Down Expand Up @@ -62,6 +68,6 @@
</div>
</footer>

<script src="/static/js/app.js"></script>
<script type="module" src="/static/js/app.js"></script>
</body>
</html>
978 changes: 57 additions & 921 deletions static/js/app.js

Large diffs are not rendered by default.

92 changes: 92 additions & 0 deletions static/js/export.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// Export and download — bulk export, per-session download, file handle helpers.

import { showToast, showConfirm } from './shared/utils.js';
import { showProjects } from './projects.js';

// ==================== Export ====================

export function bulkExport(since = 'all') {
const label = since === 'incremental' ? 'Export new sessions since last export?' : 'Export all sessions as a zip file?';
showConfirm(label, async () => {
const suffix = since === 'incremental' ? '-incremental' : '';
const fname = `claude-code-export${suffix}-${new Date().toISOString().slice(0, 10)}.zip`;
const handle = await getFileHandle(fname, [{ description: 'ZIP archive', accept: { 'application/zip': ['.zip'] } }]);
if (!handle) return;
const btnId = since === 'incremental' ? '#btn-export-since' : '#btn-export-all';
const btn = document.querySelector(btnId);
const origText = btn ? btn.textContent.trim() : '';
if (btn) { btn.disabled = true; btn.textContent = 'Exporting...'; }
try {
const res = await fetch('/api/export', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ since }),
});
const ct = res.headers.get('Content-Type') || '';
if (!res.ok) {
let msg = `Export failed: ${res.status}`;
if (ct.includes('application/json')) {
try {
const errBody = await res.json();
if (errBody.error) msg = errBody.error;
} catch (_) { /* ignore */ }
}
throw new Error(msg);
}
const blob = await res.blob();
await writeToHandle(handle, blob, fname);
showProjects();
} catch (e) {
showToast('Export failed: ' + e.message, 'error');
} finally {
if (btn) { btn.disabled = false; btn.textContent = origText; }
}
});
}

export async function downloadSession(project, sessionId) {
const fname = `session-${sessionId.slice(0, 8)}.md`;
const handle = await getFileHandle(fname, [{ description: 'Markdown', accept: { 'text/markdown': ['.md'] } }]);
if (!handle) return;
try {
const res = await fetch(`/api/export/session/${encodeURIComponent(project)}/${encodeURIComponent(sessionId)}`);
if (!res.ok) throw new Error(`Download failed: ${res.status}`);
const blob = await res.blob();
await writeToHandle(handle, blob, fname);
} catch (e) {
showToast('Download failed: ' + e.message, 'error');
}
}

async function getFileHandle(suggestedName, fileTypes) {
if (window.showSaveFilePicker) {
try {
return await window.showSaveFilePicker({ suggestedName, types: fileTypes });
} catch (e) {
if (e.name === 'AbortError') return null;
}
}
return 'fallback';
}

async function writeToHandle(handle, blob, fallbackName) {
if (handle !== 'fallback') {
const writable = await handle.createWritable();
try {
await writable.write(blob);
await writable.close();
} catch (e) {
try { await writable.abort(); } catch { /* ignore abort errors */ }
throw e;
}
} else {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fallbackName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
}
136 changes: 136 additions & 0 deletions static/js/projects.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// Projects home page — project list rendering.

import { state } from './shared/state.js';
import { esc, formatDate, smoothSet, loadingBar, setHamburgerVisible } from './shared/utils.js';
import { setWorkspaceMode } from './shared/theme.js';

// ==================== Projects (home) ====================

export async function showProjects() {
state.currentProject = null;
setHamburgerVisible(false);
setWorkspaceMode(false);
if (window.location.hash && window.location.hash !== '#') {
state.navInProgress = true;
window.location.hash = '';
setTimeout(() => { state.navInProgress = false; }, 0);
}
const content = document.getElementById('content');
content.innerHTML = '<div class="loading"><div class="spinner"></div><p>Loading projects...</p></div>';
loadingBar.start();

try {
const [projRes, stateRes] = await Promise.all([
fetch('/api/projects'),
fetch('/api/export/state').catch(() => null),
]);
if (!projRes.ok) {
let msg = `Failed to load projects (${projRes.status})`;
try { const body = await projRes.json(); if (body.error) msg = body.error; } catch { try { msg = await projRes.text() || msg; } catch { /* ignore */ } }
loadingBar.done();
smoothSet(content, `<div class="loading"><p class="text-danger">${esc(msg)}</p></div>`);
return;
}
const projects = await projRes.json();
loadingBar.done();

if (!projects.length) {
smoothSet(content, '<div class="empty-state">No Claude Code projects found.<br>Make sure Claude Code has been used on this machine.</div>');
return;
}

for (const p of projects) state.projectDisplayNames[p.name] = p.display_name || p.name;
projects.sort((a, b) => (b.last_modified || '').localeCompare(a.last_modified || ''));

let lastExportHtml = '';
let hasPreviousExport = false;
if (stateRes) {
try {
const exportState = await stateRes.json();
if (exportState.last_export_time) {
const d = new Date(exportState.last_export_time);
if (!isNaN(d.getTime())) {
hasPreviousExport = true;
const sessionCount = Math.max(0, parseInt(exportState.last_export_session_count ?? exportState.export_count ?? 0, 10) || 0);
lastExportHtml = `<p class="text-muted text-sm" style="margin:0">Last export: ${d.toLocaleString()} (${sessionCount} sessions in last export)</p>`;
}
}
} catch(e) {}
}

const sinceBtnHtml = hasPreviousExport
? `<button class="btn btn-primary btn-sm" id="btn-export-since" onclick="bulkExport('incremental')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Export new since last
</button>`
: '';

let html = `<div class="page-header">
<div>
<h1>Projects</h1>
<p class="text-muted">Browse your Claude Code conversations by project. Click on a project to view its sessions.</p>
</div>
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:0.4rem">
<div class="btn-group">
${sinceBtnHtml}
<button class="btn btn-outline btn-sm" id="btn-export-all" onclick="bulkExport('all')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Export all
</button>
</div>
${lastExportHtml}
</div>
</div>`;

const withSessions = projects.filter(p => (p.session_count || 0) > 0);
const noSessions = projects.filter(p => (p.session_count || 0) === 0);

if (withSessions.length) {
html += `<div class="card">
<div class="card-header">
<h2 class="card-title">Projects with Sessions</h2>
<p class="text-muted text-sm">${withSessions.length} project${withSessions.length !== 1 ? 's' : ''} with chat history</p>
</div>
<div class="card-body" style="padding:0">
<table class="table">
<thead><tr><th>Project</th><th>Sessions</th><th>Last Modified</th></tr></thead>
<tbody>`;
for (const p of withSessions) {
const displayName = p.display_name || p.name;
const count = p.session_count || 0;
html += `<tr>
<td><a href="#project/${encodeURIComponent(p.name)}" class="link">${esc(displayName)}</a></td>
<td><span class="text-success">${count} session${count !== 1 ? 's' : ''}</span></td>
<td>${esc(p.last_modified ? formatDate(p.last_modified) : '')}</td>
</tr>`;
}
html += `</tbody></table></div></div>`;
}

if (noSessions.length) {
html += `<div class="card" style="margin-top:1.5rem">
<div class="card-header">
<h2 class="card-title">Projects without Sessions</h2>
<p class="text-muted text-sm">${noSessions.length} project${noSessions.length !== 1 ? 's' : ''} with no sessions found</p>
</div>
<div class="card-body">
<div class="alert alert-info">These projects may have no recorded sessions yet.</div>
<table class="table">
<thead><tr><th>Project</th><th>Sessions</th><th>Last Modified</th></tr></thead>
<tbody>`;
for (const p of noSessions) {
html += `<tr>
<td><span class="text-muted">${esc(p.display_name || p.name)}</span></td>
<td><span class="text-muted">0</span></td>
<td>${esc(p.last_modified ? formatDate(p.last_modified) : '')}</td>
</tr>`;
}
html += `</tbody></table></div></div>`;
}

smoothSet(content, html);
} catch (e) {
loadingBar.done();
smoothSet(content, `<div class="loading"><p class="text-danger">Failed to load projects.</p></div>`);
}
}
65 changes: 65 additions & 0 deletions static/js/search.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Search page — search UI and result rendering.

import { esc, smoothSet, setHamburgerVisible } from './shared/utils.js';
import { setWorkspaceMode } from './shared/theme.js';

// ==================== Search ====================

export function showSearchPage() {
setHamburgerVisible(false);
setWorkspaceMode(false);
window.location.hash = '#search';
const content = document.getElementById('content');
content.innerHTML = `
<div class="search-page">
<a class="back-link" href="#" onclick="showProjects();return false;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
Back to Projects
</a>
<br><br>
<h1>Search</h1>
<div class="search-bar">
<input class="input" type="text" id="search-input" placeholder="Search conversations..." autofocus
onkeydown="if(event.key==='Enter') doSearch()">
<button class="btn btn-primary" onclick="doSearch()">Search</button>
</div>
<div id="search-results"></div>
</div>`;
document.getElementById('search-input').focus();
}

export async function doSearch() {
const input = document.getElementById('search-input');
if (!input) { showSearchPage(); return; }
const query = input.value.trim();
if (!query) return;

const container = document.getElementById('search-results');
container.innerHTML = '<div class="loading">Searching...</div>';

try {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}&limit=50`);
if (!res.ok) {
let msg = `Search failed (${res.status})`;
try { msg = await res.text() || msg; } catch { /* ignore */ }
throw new Error(msg);
}
const results = await res.json();

let html = `<p class="text-muted text-sm">${results.length} result${results.length !== 1 ? 's' : ''}</p><br>`;
html += '<div class="search-results">';

for (const r of results) {
html += `<div class="search-result" onclick="window.location.hash='#project/${encodeURIComponent(r.project)}/${encodeURIComponent(r.session_id)}'">
<div><strong>${esc(r.title)}</strong> <span class="text-muted text-sm">${esc(r.project)} &bull; ${esc(r.role)}</span></div>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
<div class="snippet">...${esc(r.snippet)}...</div>
</div>`;
}

if (!results.length) html += '<div class="empty-state">No results found.</div>';
html += '</div>';
smoothSet(container, html);
} catch (e) {
container.innerHTML = `<div class="loading">Error: ${esc(e.message)}</div>`;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Loading
Loading