chat-glass 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +71 -0
- package/agent/chat-glass.md +109 -0
- package/bin/cli.js +51 -0
- package/package.json +48 -0
- package/src/commands/clean.js +33 -0
- package/src/commands/install-agent.js +64 -0
- package/src/commands/list.js +42 -0
- package/src/commands/show.js +215 -0
- package/src/index.js +4 -0
- package/src/server/index.js +132 -0
- package/src/server/routes.js +147 -0
- package/src/server/start.js +17 -0
- package/src/server/watcher.js +51 -0
- package/src/ui/gallery.html +274 -0
- package/src/ui/main.html +500 -0
- package/src/utils/config.js +31 -0
- package/src/utils/find-chrome.js +27 -0
- package/src/utils/paths.js +17 -0
- package/src/utils/port.js +24 -0
- package/src/utils/screenshot.js +70 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { WebSocketServer } from "ws";
|
|
3
|
+
import { findFreePort } from "../utils/port.js";
|
|
4
|
+
import { ensureDirs, writeConfig, readConfig } from "../utils/config.js";
|
|
5
|
+
import { configPath } from "../utils/paths.js";
|
|
6
|
+
import { createRouter } from "./routes.js";
|
|
7
|
+
import { createWatcher } from "./watcher.js";
|
|
8
|
+
import { unlink } from "node:fs/promises";
|
|
9
|
+
|
|
10
|
+
const IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
|
|
11
|
+
const IDLE_CHECK_INTERVAL_MS = 60 * 1000; // 60 seconds
|
|
12
|
+
|
|
13
|
+
export async function startServer(projectDir) {
|
|
14
|
+
await ensureDirs(projectDir);
|
|
15
|
+
|
|
16
|
+
const port = await findFreePort();
|
|
17
|
+
const startedAt = Date.now();
|
|
18
|
+
let lastActivity = Date.now();
|
|
19
|
+
|
|
20
|
+
function touchActivity() {
|
|
21
|
+
lastActivity = Date.now();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// -- WebSocket clients --
|
|
25
|
+
const clients = new Set();
|
|
26
|
+
|
|
27
|
+
function broadcast(message) {
|
|
28
|
+
for (const ws of clients) {
|
|
29
|
+
if (ws.readyState === ws.OPEN) {
|
|
30
|
+
ws.send(message);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// -- Render-complete signal --
|
|
36
|
+
let renderCompleteResolve = null;
|
|
37
|
+
|
|
38
|
+
function waitForRenderComplete(timeoutMs = 5000) {
|
|
39
|
+
return new Promise((resolve) => {
|
|
40
|
+
const timer = setTimeout(() => {
|
|
41
|
+
renderCompleteResolve = null;
|
|
42
|
+
resolve();
|
|
43
|
+
}, timeoutMs);
|
|
44
|
+
renderCompleteResolve = () => {
|
|
45
|
+
clearTimeout(timer);
|
|
46
|
+
renderCompleteResolve = null;
|
|
47
|
+
resolve();
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// -- HTTP server --
|
|
53
|
+
const router = createRouter(projectDir, {
|
|
54
|
+
broadcast,
|
|
55
|
+
touchActivity,
|
|
56
|
+
serverInfo: () => ({ port, startedAt }),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const httpServer = createServer(router);
|
|
60
|
+
|
|
61
|
+
// -- WebSocket server --
|
|
62
|
+
const wss = new WebSocketServer({ server: httpServer, path: "/ws" });
|
|
63
|
+
|
|
64
|
+
wss.on("connection", (ws) => {
|
|
65
|
+
clients.add(ws);
|
|
66
|
+
touchActivity();
|
|
67
|
+
ws.on("message", (data) => {
|
|
68
|
+
try {
|
|
69
|
+
const msg = JSON.parse(data.toString());
|
|
70
|
+
if (msg.type === "render-complete" && renderCompleteResolve) {
|
|
71
|
+
renderCompleteResolve();
|
|
72
|
+
}
|
|
73
|
+
} catch {
|
|
74
|
+
// ignore malformed messages
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
ws.on("close", () => clients.delete(ws));
|
|
78
|
+
ws.on("error", () => clients.delete(ws));
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// -- File watcher --
|
|
82
|
+
const watcher = createWatcher(projectDir, () => {
|
|
83
|
+
touchActivity();
|
|
84
|
+
broadcast(JSON.stringify({ type: "reload" }));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// -- Auto-shutdown --
|
|
88
|
+
const idleTimer = setInterval(async () => {
|
|
89
|
+
if (Date.now() - lastActivity > IDLE_TIMEOUT_MS) {
|
|
90
|
+
await close();
|
|
91
|
+
}
|
|
92
|
+
}, IDLE_CHECK_INTERVAL_MS);
|
|
93
|
+
idleTimer.unref();
|
|
94
|
+
|
|
95
|
+
// -- Write config --
|
|
96
|
+
await writeConfig(projectDir, {
|
|
97
|
+
port,
|
|
98
|
+
pid: process.pid,
|
|
99
|
+
lastActivity: new Date(lastActivity).toISOString(),
|
|
100
|
+
createdAt: new Date(startedAt).toISOString(),
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// -- Start listening --
|
|
104
|
+
await new Promise((resolve, reject) => {
|
|
105
|
+
httpServer.listen(port, "127.0.0.1", resolve);
|
|
106
|
+
httpServer.on("error", reject);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// -- Clean shutdown --
|
|
110
|
+
async function close() {
|
|
111
|
+
clearInterval(idleTimer);
|
|
112
|
+
watcher.close();
|
|
113
|
+
|
|
114
|
+
for (const ws of clients) {
|
|
115
|
+
ws.close();
|
|
116
|
+
}
|
|
117
|
+
clients.clear();
|
|
118
|
+
|
|
119
|
+
wss.close();
|
|
120
|
+
|
|
121
|
+
await new Promise((resolve) => httpServer.close(resolve));
|
|
122
|
+
|
|
123
|
+
// Remove config file best-effort
|
|
124
|
+
try {
|
|
125
|
+
await unlink(configPath(projectDir));
|
|
126
|
+
} catch {
|
|
127
|
+
// ignore
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return { port, close, waitForRenderComplete };
|
|
132
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import { join, extname } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { dirname } from "node:path";
|
|
5
|
+
import { pagesDir } from "../utils/paths.js";
|
|
6
|
+
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
8
|
+
const __dirname = dirname(__filename);
|
|
9
|
+
const uiDir = join(__dirname, "..", "ui");
|
|
10
|
+
|
|
11
|
+
function sendJSON(res, statusCode, data) {
|
|
12
|
+
const body = JSON.stringify(data);
|
|
13
|
+
res.writeHead(statusCode, {
|
|
14
|
+
"Content-Type": "application/json",
|
|
15
|
+
"Content-Length": Buffer.byteLength(body),
|
|
16
|
+
});
|
|
17
|
+
res.end(body);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function sendHTML(res, statusCode, html) {
|
|
21
|
+
res.writeHead(statusCode, {
|
|
22
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
23
|
+
"Content-Length": Buffer.byteLength(html),
|
|
24
|
+
});
|
|
25
|
+
res.end(html);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function send404(res) {
|
|
29
|
+
sendHTML(res, 404, "<h1>404 Not Found</h1>");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function serveUIFile(res, filename) {
|
|
33
|
+
try {
|
|
34
|
+
const html = await readFile(join(uiDir, filename), "utf8");
|
|
35
|
+
sendHTML(res, 200, html);
|
|
36
|
+
} catch {
|
|
37
|
+
sendHTML(res, 200, `<html><body><h1>chat-glass</h1><p>UI not built yet.</p></body></html>`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function extractTitle(html) {
|
|
42
|
+
const match = html.match(/<title>(.*?)<\/title>/i);
|
|
43
|
+
return match ? match[1] : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function extractTimestamp(filename) {
|
|
47
|
+
const match = filename.match(/^(\d{4}-\d{2}-\d{2}T[\d-]+)/);
|
|
48
|
+
if (match) return match[1];
|
|
49
|
+
const dateMatch = filename.match(/^(\d{4}-\d{2}-\d{2})/);
|
|
50
|
+
return dateMatch ? dateMatch[1] : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function createRouter(projectDir, { broadcast, touchActivity, serverInfo }) {
|
|
54
|
+
const pages = pagesDir(projectDir);
|
|
55
|
+
|
|
56
|
+
return async function handleRequest(req, res) {
|
|
57
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
58
|
+
const path = url.pathname;
|
|
59
|
+
|
|
60
|
+
if (req.method !== "GET") {
|
|
61
|
+
res.writeHead(405, { "Content-Type": "text/plain" });
|
|
62
|
+
res.end("Method Not Allowed");
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// GET /
|
|
67
|
+
if (path === "/") {
|
|
68
|
+
await serveUIFile(res, "main.html");
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// GET /gallery
|
|
73
|
+
if (path === "/gallery") {
|
|
74
|
+
await serveUIFile(res, "gallery.html");
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// GET /reload
|
|
79
|
+
if (path === "/reload") {
|
|
80
|
+
touchActivity();
|
|
81
|
+
broadcast(JSON.stringify({ type: "reload" }));
|
|
82
|
+
sendJSON(res, 200, { ok: true });
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// GET /health
|
|
87
|
+
if (path === "/health") {
|
|
88
|
+
const info = serverInfo();
|
|
89
|
+
sendJSON(res, 200, {
|
|
90
|
+
port: info.port,
|
|
91
|
+
pid: process.pid,
|
|
92
|
+
uptime: Math.floor((Date.now() - info.startedAt) / 1000),
|
|
93
|
+
});
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// GET /api/pages
|
|
98
|
+
if (path === "/api/pages") {
|
|
99
|
+
try {
|
|
100
|
+
const files = await readdir(pages);
|
|
101
|
+
const htmlFiles = files.filter(
|
|
102
|
+
(f) => extname(f) === ".html" && f !== "latest.html"
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const results = await Promise.all(
|
|
106
|
+
htmlFiles.map(async (filename) => {
|
|
107
|
+
let title = null;
|
|
108
|
+
try {
|
|
109
|
+
const content = await readFile(join(pages, filename), "utf8");
|
|
110
|
+
title = extractTitle(content);
|
|
111
|
+
} catch {
|
|
112
|
+
// ignore unreadable files
|
|
113
|
+
}
|
|
114
|
+
const timestamp = extractTimestamp(filename);
|
|
115
|
+
return { filename, title, timestamp };
|
|
116
|
+
})
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
// Sort by filename descending (newest first, assuming timestamp-based names)
|
|
120
|
+
results.sort((a, b) => b.filename.localeCompare(a.filename));
|
|
121
|
+
sendJSON(res, 200, results);
|
|
122
|
+
} catch {
|
|
123
|
+
sendJSON(res, 200, []);
|
|
124
|
+
}
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// GET /pages/:filename
|
|
129
|
+
if (path.startsWith("/pages/")) {
|
|
130
|
+
const filename = path.slice("/pages/".length);
|
|
131
|
+
// Prevent path traversal
|
|
132
|
+
if (filename.includes("..") || filename.includes("/")) {
|
|
133
|
+
send404(res);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
const html = await readFile(join(pages, filename), "utf8");
|
|
138
|
+
sendHTML(res, 200, html);
|
|
139
|
+
} catch {
|
|
140
|
+
send404(res);
|
|
141
|
+
}
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
send404(res);
|
|
146
|
+
};
|
|
147
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// Standalone entry point for running the server as a detached background process.
|
|
4
|
+
// Invoked by the show command via child_process.spawn.
|
|
5
|
+
|
|
6
|
+
import { startServer } from "./index.js";
|
|
7
|
+
|
|
8
|
+
const projectDir = process.argv[2] || process.cwd();
|
|
9
|
+
|
|
10
|
+
try {
|
|
11
|
+
const { port } = await startServer(projectDir);
|
|
12
|
+
// Write port to stdout so the parent process can read it
|
|
13
|
+
process.stdout.write(String(port));
|
|
14
|
+
} catch (err) {
|
|
15
|
+
process.stderr.write(err.message);
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { watch } from "node:fs";
|
|
2
|
+
import { pagesDir } from "../utils/paths.js";
|
|
3
|
+
|
|
4
|
+
export function createWatcher(projectDir, onChange) {
|
|
5
|
+
const dir = pagesDir(projectDir);
|
|
6
|
+
let debounceTimer = null;
|
|
7
|
+
let watcher = null;
|
|
8
|
+
|
|
9
|
+
function start() {
|
|
10
|
+
try {
|
|
11
|
+
watcher = watch(dir, { persistent: false }, (_event, filename) => {
|
|
12
|
+
// Only react to HTML file changes — ignore screenshots, temp files, etc.
|
|
13
|
+
if (filename && !filename.endsWith(".html")) return;
|
|
14
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
15
|
+
debounceTimer = setTimeout(() => {
|
|
16
|
+
debounceTimer = null;
|
|
17
|
+
onChange();
|
|
18
|
+
}, 100);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
watcher.on("error", (err) => {
|
|
22
|
+
// Directory may be deleted and recreated — restart watcher
|
|
23
|
+
close();
|
|
24
|
+
setTimeout(() => {
|
|
25
|
+
try {
|
|
26
|
+
start();
|
|
27
|
+
} catch {
|
|
28
|
+
// directory doesn't exist yet, will retry on next change
|
|
29
|
+
}
|
|
30
|
+
}, 1000);
|
|
31
|
+
});
|
|
32
|
+
} catch {
|
|
33
|
+
// directory doesn't exist yet — that's fine
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function close() {
|
|
38
|
+
if (debounceTimer) {
|
|
39
|
+
clearTimeout(debounceTimer);
|
|
40
|
+
debounceTimer = null;
|
|
41
|
+
}
|
|
42
|
+
if (watcher) {
|
|
43
|
+
watcher.close();
|
|
44
|
+
watcher = null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
start();
|
|
49
|
+
|
|
50
|
+
return { close };
|
|
51
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>chat-glass - gallery</title>
|
|
7
|
+
<style>
|
|
8
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
9
|
+
|
|
10
|
+
html, body {
|
|
11
|
+
min-height: 100%;
|
|
12
|
+
background: #1a1a2e;
|
|
13
|
+
color: #e0e0e0;
|
|
14
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/* Header */
|
|
18
|
+
#header {
|
|
19
|
+
display: flex;
|
|
20
|
+
align-items: center;
|
|
21
|
+
gap: 12px;
|
|
22
|
+
padding: 12px 24px;
|
|
23
|
+
background: #0f0f23;
|
|
24
|
+
border-bottom: 1px solid #2a2a4a;
|
|
25
|
+
position: sticky;
|
|
26
|
+
top: 0;
|
|
27
|
+
z-index: 10;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
#header .brand {
|
|
31
|
+
font-size: 14px;
|
|
32
|
+
font-weight: 700;
|
|
33
|
+
color: #00d4ff;
|
|
34
|
+
letter-spacing: 0.5px;
|
|
35
|
+
text-decoration: none;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
#header .brand:hover {
|
|
39
|
+
color: #33dfff;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
#header .separator {
|
|
43
|
+
width: 1px;
|
|
44
|
+
height: 16px;
|
|
45
|
+
background: #2a2a4a;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
#header .view-title {
|
|
49
|
+
font-size: 14px;
|
|
50
|
+
color: #888;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
#header .count {
|
|
54
|
+
font-size: 12px;
|
|
55
|
+
color: #555;
|
|
56
|
+
margin-left: auto;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
#ws-indicator {
|
|
60
|
+
width: 8px;
|
|
61
|
+
height: 8px;
|
|
62
|
+
border-radius: 50%;
|
|
63
|
+
background: #00d4ff;
|
|
64
|
+
flex-shrink: 0;
|
|
65
|
+
transition: background 0.3s;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
#ws-indicator.disconnected {
|
|
69
|
+
background: #ff4444;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* Grid */
|
|
73
|
+
#grid {
|
|
74
|
+
display: grid;
|
|
75
|
+
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
76
|
+
gap: 16px;
|
|
77
|
+
padding: 24px;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.card {
|
|
81
|
+
background: #16213e;
|
|
82
|
+
border: 1px solid #2a2a4a;
|
|
83
|
+
border-radius: 8px;
|
|
84
|
+
padding: 20px;
|
|
85
|
+
cursor: pointer;
|
|
86
|
+
transition: border-color 0.15s, transform 0.15s, box-shadow 0.15s;
|
|
87
|
+
text-decoration: none;
|
|
88
|
+
display: flex;
|
|
89
|
+
flex-direction: column;
|
|
90
|
+
gap: 8px;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.card:hover {
|
|
94
|
+
border-color: #00d4ff;
|
|
95
|
+
transform: translateY(-2px);
|
|
96
|
+
box-shadow: 0 4px 12px rgba(0, 212, 255, 0.1);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.card .card-title {
|
|
100
|
+
font-size: 14px;
|
|
101
|
+
color: #e0e0e0;
|
|
102
|
+
line-height: 1.4;
|
|
103
|
+
word-break: break-word;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.card:hover .card-title {
|
|
107
|
+
color: #00d4ff;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.card .card-time {
|
|
111
|
+
font-size: 11px;
|
|
112
|
+
color: #666;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.card .card-filename {
|
|
116
|
+
font-size: 10px;
|
|
117
|
+
color: #444;
|
|
118
|
+
font-family: 'SF Mono', 'Fira Code', monospace;
|
|
119
|
+
overflow: hidden;
|
|
120
|
+
text-overflow: ellipsis;
|
|
121
|
+
white-space: nowrap;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/* Empty state */
|
|
125
|
+
#empty-state {
|
|
126
|
+
display: flex;
|
|
127
|
+
flex-direction: column;
|
|
128
|
+
align-items: center;
|
|
129
|
+
justify-content: center;
|
|
130
|
+
padding: 80px 24px;
|
|
131
|
+
gap: 16px;
|
|
132
|
+
color: #888;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
#empty-state .icon {
|
|
136
|
+
font-size: 48px;
|
|
137
|
+
opacity: 0.3;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
#empty-state .message {
|
|
141
|
+
font-size: 16px;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
#empty-state .hint {
|
|
145
|
+
font-size: 12px;
|
|
146
|
+
color: #555;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.hidden { display: none !important; }
|
|
150
|
+
</style>
|
|
151
|
+
</head>
|
|
152
|
+
<body>
|
|
153
|
+
<div id="header">
|
|
154
|
+
<a class="brand" href="/">chat-glass</a>
|
|
155
|
+
<span class="separator"></span>
|
|
156
|
+
<span class="view-title">Gallery</span>
|
|
157
|
+
<span class="count" id="page-count"></span>
|
|
158
|
+
<div id="ws-indicator" title="WebSocket connected"></div>
|
|
159
|
+
</div>
|
|
160
|
+
|
|
161
|
+
<div id="grid"></div>
|
|
162
|
+
|
|
163
|
+
<div id="empty-state" class="hidden">
|
|
164
|
+
<div class="icon">◈</div>
|
|
165
|
+
<div class="message">Waiting for Claude to create a visualization...</div>
|
|
166
|
+
<div class="hint">Use chat-glass show <file.html> to display content</div>
|
|
167
|
+
</div>
|
|
168
|
+
|
|
169
|
+
<script>
|
|
170
|
+
const grid = document.getElementById('grid');
|
|
171
|
+
const emptyState = document.getElementById('empty-state');
|
|
172
|
+
const pageCount = document.getElementById('page-count');
|
|
173
|
+
const wsIndicator = document.getElementById('ws-indicator');
|
|
174
|
+
|
|
175
|
+
function formatTimestamp(ts) {
|
|
176
|
+
if (!ts) return '';
|
|
177
|
+
const cleaned = ts.replace(/T/, ' ').replace(/-(\d{2})-(\d{2})-(\d+)$/, ':$1:$2');
|
|
178
|
+
return cleaned;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function renderGrid(pages) {
|
|
182
|
+
if (pages.length === 0) {
|
|
183
|
+
grid.innerHTML = '';
|
|
184
|
+
emptyState.classList.remove('hidden');
|
|
185
|
+
pageCount.textContent = '';
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
emptyState.classList.add('hidden');
|
|
190
|
+
pageCount.textContent = pages.length + ' visualization' + (pages.length !== 1 ? 's' : '');
|
|
191
|
+
|
|
192
|
+
grid.innerHTML = pages.map(page => {
|
|
193
|
+
const title = page.title || page.filename;
|
|
194
|
+
const time = formatTimestamp(page.timestamp);
|
|
195
|
+
const href = '/?page=' + encodeURIComponent(page.filename);
|
|
196
|
+
return `<a class="card" href="${href}">
|
|
197
|
+
<div class="card-title">${escapeHtml(title)}</div>
|
|
198
|
+
<div class="card-time">${escapeHtml(time)}</div>
|
|
199
|
+
<div class="card-filename">${escapeHtml(page.filename)}</div>
|
|
200
|
+
</a>`;
|
|
201
|
+
}).join('');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function escapeHtml(str) {
|
|
205
|
+
const div = document.createElement('div');
|
|
206
|
+
div.textContent = str;
|
|
207
|
+
return div.innerHTML;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function loadPages() {
|
|
211
|
+
try {
|
|
212
|
+
const res = await fetch('/api/pages');
|
|
213
|
+
if (!res.ok) throw new Error('Failed to fetch');
|
|
214
|
+
const pages = await res.json();
|
|
215
|
+
renderGrid(pages);
|
|
216
|
+
} catch {
|
|
217
|
+
renderGrid([]);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// -- Keyboard shortcut --
|
|
222
|
+
document.addEventListener('keydown', (e) => {
|
|
223
|
+
if (e.key === 'g' || e.key === 'G') {
|
|
224
|
+
window.location.href = '/';
|
|
225
|
+
}
|
|
226
|
+
if (e.key === 'Escape') {
|
|
227
|
+
window.location.href = '/';
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// -- WebSocket with auto-reconnect --
|
|
232
|
+
let ws = null;
|
|
233
|
+
let wsReconnectDelay = 500;
|
|
234
|
+
const WS_MAX_DELAY = 10000;
|
|
235
|
+
|
|
236
|
+
function connectWebSocket() {
|
|
237
|
+
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
238
|
+
ws = new WebSocket(`${protocol}//${location.host}/ws`);
|
|
239
|
+
|
|
240
|
+
ws.onopen = () => {
|
|
241
|
+
wsReconnectDelay = 500;
|
|
242
|
+
wsIndicator.classList.remove('disconnected');
|
|
243
|
+
wsIndicator.title = 'WebSocket connected';
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
ws.onmessage = (e) => {
|
|
247
|
+
try {
|
|
248
|
+
const msg = JSON.parse(e.data);
|
|
249
|
+
if (msg.type === 'reload') loadPages();
|
|
250
|
+
} catch {}
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
ws.onclose = () => {
|
|
254
|
+
wsIndicator.classList.add('disconnected');
|
|
255
|
+
wsIndicator.title = 'WebSocket disconnected - reconnecting...';
|
|
256
|
+
scheduleReconnect();
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
ws.onerror = () => {};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function scheduleReconnect() {
|
|
263
|
+
setTimeout(() => {
|
|
264
|
+
connectWebSocket();
|
|
265
|
+
wsReconnectDelay = Math.min(wsReconnectDelay * 2, WS_MAX_DELAY);
|
|
266
|
+
}, wsReconnectDelay);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// -- Boot --
|
|
270
|
+
connectWebSocket();
|
|
271
|
+
loadPages();
|
|
272
|
+
</script>
|
|
273
|
+
</body>
|
|
274
|
+
</html>
|