figure-viewer 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/.opencode/commands/figures.md +17 -0
- package/README.md +99 -0
- package/figure-viewer-spec.md +293 -0
- package/figure-viewer.config.json +12 -0
- package/figure-viewer.js +186 -0
- package/lib/config.js +44 -0
- package/lib/discover.js +54 -0
- package/lib/history.js +130 -0
- package/lib/html.js +486 -0
- package/lib/watcher-daemon.js +96 -0
- package/lib/watcher.js +43 -0
- package/package.json +33 -0
package/lib/history.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
const HISTORY_FILE = path.join(process.env.HOME || process.env.USERPROFILE, '.figure-viewer', 'history.json');
|
|
5
|
+
|
|
6
|
+
function ensureHistoryDir() {
|
|
7
|
+
const dir = path.dirname(HISTORY_FILE);
|
|
8
|
+
if (!fs.existsSync(dir)) {
|
|
9
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function loadHistory() {
|
|
14
|
+
ensureHistoryDir();
|
|
15
|
+
if (!fs.existsSync(HISTORY_FILE)) {
|
|
16
|
+
return {};
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(fs.readFileSync(HISTORY_FILE, 'utf8'));
|
|
20
|
+
} catch {
|
|
21
|
+
return {};
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function saveHistory(history) {
|
|
26
|
+
ensureHistoryDir();
|
|
27
|
+
fs.writeFileSync(HISTORY_FILE, JSON.stringify(history, null, 2));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function getFigureStatus(figurePath, mtime) {
|
|
31
|
+
const now = Date.now();
|
|
32
|
+
const age = now - mtime;
|
|
33
|
+
const fiveMinutes = 5 * 60 * 1000;
|
|
34
|
+
const oneHour = 60 * 60 * 1000;
|
|
35
|
+
|
|
36
|
+
if (age < fiveMinutes) {
|
|
37
|
+
return 'active';
|
|
38
|
+
} else if (age < oneHour) {
|
|
39
|
+
return 'recent';
|
|
40
|
+
}
|
|
41
|
+
return 'older';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function getRelativeTime(mtime) {
|
|
45
|
+
const now = Date.now();
|
|
46
|
+
const diff = now - mtime;
|
|
47
|
+
const seconds = Math.floor(diff / 1000);
|
|
48
|
+
const minutes = Math.floor(seconds / 60);
|
|
49
|
+
const hours = Math.floor(minutes / 60);
|
|
50
|
+
const days = Math.floor(hours / 24);
|
|
51
|
+
|
|
52
|
+
if (seconds < 60) return 'just now';
|
|
53
|
+
if (minutes < 60) return `${minutes} min${minutes > 1 ? 's' : ''} ago`;
|
|
54
|
+
if (hours < 24) return `${hours} hour${hours > 1 ? 's' : ''} ago`;
|
|
55
|
+
return `${days} day${days > 1 ? 's' : ''} ago`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function updateHistory(figuresDir, figures) {
|
|
59
|
+
const history = loadHistory();
|
|
60
|
+
|
|
61
|
+
if (!history[figuresDir]) {
|
|
62
|
+
history[figuresDir] = {};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const dirHistory = history[figuresDir];
|
|
66
|
+
const now = Date.now();
|
|
67
|
+
|
|
68
|
+
// Update or add figures
|
|
69
|
+
for (const figure of figures) {
|
|
70
|
+
const existing = dirHistory[figure.name];
|
|
71
|
+
|
|
72
|
+
if (existing) {
|
|
73
|
+
if (figure.mtime > existing.mtime) {
|
|
74
|
+
// Same file with newer mtime - increment version
|
|
75
|
+
dirHistory[figure.name] = {
|
|
76
|
+
mtime: figure.mtime,
|
|
77
|
+
version: existing.version + 1,
|
|
78
|
+
firstSeen: existing.firstSeen || existing.mtime
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
// If mtime hasn't changed, keep existing version
|
|
82
|
+
} else {
|
|
83
|
+
// New figure
|
|
84
|
+
dirHistory[figure.name] = {
|
|
85
|
+
mtime: figure.mtime,
|
|
86
|
+
version: 1,
|
|
87
|
+
firstSeen: figure.mtime
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Remove figures that no longer exist
|
|
93
|
+
const figureNames = new Set(figures.map(f => f.name));
|
|
94
|
+
for (const name of Object.keys(dirHistory)) {
|
|
95
|
+
if (!figureNames.has(name)) {
|
|
96
|
+
delete dirHistory[name];
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
saveHistory(history);
|
|
101
|
+
return dirHistory;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function getVersion(figurePath, history) {
|
|
105
|
+
const name = path.basename(figurePath);
|
|
106
|
+
return history[name]?.version || 1;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function clearHistory() {
|
|
110
|
+
if (fs.existsSync(HISTORY_FILE)) {
|
|
111
|
+
fs.unlinkSync(HISTORY_FILE);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function getHistoryForDir(figuresDir) {
|
|
116
|
+
const history = loadHistory();
|
|
117
|
+
return history[figuresDir] || {};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
module.exports = {
|
|
121
|
+
loadHistory,
|
|
122
|
+
saveHistory,
|
|
123
|
+
getFigureStatus,
|
|
124
|
+
getRelativeTime,
|
|
125
|
+
updateHistory,
|
|
126
|
+
getVersion,
|
|
127
|
+
clearHistory,
|
|
128
|
+
getHistoryForDir,
|
|
129
|
+
HISTORY_FILE
|
|
130
|
+
};
|
package/lib/html.js
ADDED
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { getFigureStatus, getRelativeTime, getVersion } = require('./history');
|
|
4
|
+
const { getConfig, DEFAULT_CONFIG } = require('./config');
|
|
5
|
+
|
|
6
|
+
const SUPPORTED_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.pdf', '.svg', '.gif', '.webp'];
|
|
7
|
+
|
|
8
|
+
function escapeHtml(str) {
|
|
9
|
+
return str
|
|
10
|
+
.replace(/&/g, '&')
|
|
11
|
+
.replace(/</g, '<')
|
|
12
|
+
.replace(/>/g, '>')
|
|
13
|
+
.replace(/"/g, '"')
|
|
14
|
+
.replace(/'/g, ''');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function escapeFileUrl(filepath) {
|
|
18
|
+
// Use encodeURIComponent only for special chars, not the entire URL
|
|
19
|
+
const encoded = filepath.split('/').map(p => encodeURIComponent(p)).join('/');
|
|
20
|
+
return 'file://' + encoded;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function formatFileSize(bytes) {
|
|
24
|
+
if (bytes < 1024) return bytes + ' B';
|
|
25
|
+
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
|
26
|
+
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function getImages(figuresDir) {
|
|
30
|
+
if (!fs.existsSync(figuresDir)) {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const files = fs.readdirSync(figuresDir);
|
|
35
|
+
const images = [];
|
|
36
|
+
|
|
37
|
+
for (const file of files) {
|
|
38
|
+
const ext = path.extname(file).toLowerCase();
|
|
39
|
+
if (!SUPPORTED_EXTENSIONS.includes(ext)) continue;
|
|
40
|
+
|
|
41
|
+
const filePath = path.join(figuresDir, file);
|
|
42
|
+
const stats = fs.statSync(filePath);
|
|
43
|
+
|
|
44
|
+
if (!stats.isFile()) continue;
|
|
45
|
+
|
|
46
|
+
images.push({
|
|
47
|
+
name: file,
|
|
48
|
+
path: filePath,
|
|
49
|
+
mtime: stats.mtimeMs,
|
|
50
|
+
size: stats.size,
|
|
51
|
+
ext: ext
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Sort by mtime, newest first
|
|
56
|
+
images.sort((a, b) => b.mtime - a.mtime);
|
|
57
|
+
|
|
58
|
+
return images;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function generateFigureCards(figuresDir, history) {
|
|
62
|
+
const images = getImages(figuresDir);
|
|
63
|
+
|
|
64
|
+
if (images.length === 0) {
|
|
65
|
+
return '';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return images.map((img, index) => {
|
|
69
|
+
const status = getFigureStatus(img.path, img.mtime);
|
|
70
|
+
const relativeTime = getRelativeTime(img.mtime);
|
|
71
|
+
const version = getVersion(img.path, history);
|
|
72
|
+
|
|
73
|
+
const statusColors = {
|
|
74
|
+
active: '#ef4444', // Red
|
|
75
|
+
recent: '#eab308', // Yellow
|
|
76
|
+
older: '#9ca3af' // Gray
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const statusLabels = {
|
|
80
|
+
active: 'Active',
|
|
81
|
+
recent: 'Recent',
|
|
82
|
+
older: 'Older'
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const borderColor = statusColors[status];
|
|
86
|
+
const fileUrl = escapeFileUrl(img.path);
|
|
87
|
+
const truncatedName = img.name.length > 25
|
|
88
|
+
? img.name.substring(0, 22) + '...'
|
|
89
|
+
: img.name;
|
|
90
|
+
|
|
91
|
+
return `
|
|
92
|
+
<div class="figure-card"
|
|
93
|
+
data-index="${index}"
|
|
94
|
+
data-path="${escapeHtml(img.path)}"
|
|
95
|
+
data-name="${escapeHtml(img.name)}"
|
|
96
|
+
data-size="${img.size}"
|
|
97
|
+
data-mtime="${img.mtime}"
|
|
98
|
+
onclick="openLightbox(${index})">
|
|
99
|
+
<div class="figure-image" style="border-color: ${borderColor}">
|
|
100
|
+
<img src="${fileUrl}" alt="${escapeHtml(img.name)}" loading="lazy">
|
|
101
|
+
<span class="status-indicator" style="background-color: ${borderColor}"></span>
|
|
102
|
+
</div>
|
|
103
|
+
<div class="figure-info">
|
|
104
|
+
<div class="figure-name" title="${escapeHtml(img.name)}">${escapeHtml(truncatedName)}</div>
|
|
105
|
+
<div class="figure-meta">
|
|
106
|
+
<span class="figure-time">${relativeTime}</span>
|
|
107
|
+
${version > 1 ? `<span class="figure-version">v${version}</span>` : ''}
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
`.trim();
|
|
112
|
+
}).join('\n');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function generateHtml(figuresDir, options = {}) {
|
|
116
|
+
const images = getImages(figuresDir);
|
|
117
|
+
const history = options.history || {};
|
|
118
|
+
const config = options.config || getConfig();
|
|
119
|
+
const refreshInterval = config.refreshInterval || DEFAULT_CONFIG.refreshInterval;
|
|
120
|
+
const figureCards = generateFigureCards(figuresDir, history);
|
|
121
|
+
|
|
122
|
+
const hasImages = images.length > 0;
|
|
123
|
+
const emptyMessage = !hasImages
|
|
124
|
+
? '<div class="empty-state"><p>No figures yet.</p><p>Run your analysis to generate figures.</p></div>'
|
|
125
|
+
: '';
|
|
126
|
+
|
|
127
|
+
const imagesJson = JSON.stringify(images.map(img => ({
|
|
128
|
+
name: img.name,
|
|
129
|
+
path: escapeFileUrl(img.path),
|
|
130
|
+
size: img.size,
|
|
131
|
+
mtime: img.mtime,
|
|
132
|
+
ext: img.ext
|
|
133
|
+
})));
|
|
134
|
+
|
|
135
|
+
return `<!DOCTYPE html>
|
|
136
|
+
<html lang="en">
|
|
137
|
+
<head>
|
|
138
|
+
<meta charset="UTF-8">
|
|
139
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
140
|
+
<title>Figure Viewer</title>
|
|
141
|
+
<style>
|
|
142
|
+
* {
|
|
143
|
+
margin: 0;
|
|
144
|
+
padding: 0;
|
|
145
|
+
box-sizing: border-box;
|
|
146
|
+
}
|
|
147
|
+
body {
|
|
148
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
149
|
+
background-color: #1a1a1a;
|
|
150
|
+
color: #e5e5e5;
|
|
151
|
+
min-height: 100vh;
|
|
152
|
+
}
|
|
153
|
+
.header {
|
|
154
|
+
display: flex;
|
|
155
|
+
justify-content: space-between;
|
|
156
|
+
align-items: center;
|
|
157
|
+
padding: 16px 24px;
|
|
158
|
+
background-color: #262626;
|
|
159
|
+
border-bottom: 1px solid #404040;
|
|
160
|
+
}
|
|
161
|
+
.header h1 {
|
|
162
|
+
font-size: 18px;
|
|
163
|
+
font-weight: 600;
|
|
164
|
+
display: flex;
|
|
165
|
+
align-items: center;
|
|
166
|
+
gap: 8px;
|
|
167
|
+
}
|
|
168
|
+
.header-controls {
|
|
169
|
+
display: flex;
|
|
170
|
+
align-items: center;
|
|
171
|
+
gap: 16px;
|
|
172
|
+
}
|
|
173
|
+
.filter-input {
|
|
174
|
+
padding: 8px 12px;
|
|
175
|
+
border-radius: 6px;
|
|
176
|
+
border: 1px solid #404040;
|
|
177
|
+
background-color: #1a1a1a;
|
|
178
|
+
color: #e5e5e5;
|
|
179
|
+
font-size: 14px;
|
|
180
|
+
width: 200px;
|
|
181
|
+
}
|
|
182
|
+
.filter-input:focus {
|
|
183
|
+
outline: none;
|
|
184
|
+
border-color: #6366f1;
|
|
185
|
+
}
|
|
186
|
+
.refresh-btn {
|
|
187
|
+
padding: 8px 16px;
|
|
188
|
+
border-radius: 6px;
|
|
189
|
+
border: none;
|
|
190
|
+
background-color: #6366f1;
|
|
191
|
+
color: white;
|
|
192
|
+
font-size: 14px;
|
|
193
|
+
cursor: pointer;
|
|
194
|
+
transition: background-color 0.2s;
|
|
195
|
+
}
|
|
196
|
+
.refresh-btn:hover {
|
|
197
|
+
background-color: #4f46e5;
|
|
198
|
+
}
|
|
199
|
+
.stats {
|
|
200
|
+
padding: 12px 24px;
|
|
201
|
+
background-color: #262626;
|
|
202
|
+
border-bottom: 1px solid #404040;
|
|
203
|
+
font-size: 13px;
|
|
204
|
+
color: #9ca3af;
|
|
205
|
+
}
|
|
206
|
+
.grid {
|
|
207
|
+
display: grid;
|
|
208
|
+
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
|
209
|
+
gap: 20px;
|
|
210
|
+
padding: 24px;
|
|
211
|
+
}
|
|
212
|
+
.figure-card {
|
|
213
|
+
background-color: #262626;
|
|
214
|
+
border-radius: 12px;
|
|
215
|
+
overflow: hidden;
|
|
216
|
+
cursor: pointer;
|
|
217
|
+
transition: transform 0.2s, box-shadow 0.2s;
|
|
218
|
+
}
|
|
219
|
+
.figure-card:hover {
|
|
220
|
+
transform: translateY(-4px);
|
|
221
|
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
|
222
|
+
}
|
|
223
|
+
.figure-image {
|
|
224
|
+
position: relative;
|
|
225
|
+
aspect-ratio: 16/10;
|
|
226
|
+
background-color: #1a1a1a;
|
|
227
|
+
border-bottom: 3px solid;
|
|
228
|
+
overflow: hidden;
|
|
229
|
+
}
|
|
230
|
+
.figure-image img {
|
|
231
|
+
width: 100%;
|
|
232
|
+
height: 100%;
|
|
233
|
+
object-fit: contain;
|
|
234
|
+
}
|
|
235
|
+
.status-indicator {
|
|
236
|
+
position: absolute;
|
|
237
|
+
top: 8px;
|
|
238
|
+
right: 8px;
|
|
239
|
+
width: 10px;
|
|
240
|
+
height: 10px;
|
|
241
|
+
border-radius: 50%;
|
|
242
|
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
|
243
|
+
}
|
|
244
|
+
.figure-info {
|
|
245
|
+
padding: 12px;
|
|
246
|
+
}
|
|
247
|
+
.figure-name {
|
|
248
|
+
font-size: 14px;
|
|
249
|
+
font-weight: 500;
|
|
250
|
+
white-space: nowrap;
|
|
251
|
+
overflow: hidden;
|
|
252
|
+
text-overflow: ellipsis;
|
|
253
|
+
margin-bottom: 4px;
|
|
254
|
+
}
|
|
255
|
+
.figure-meta {
|
|
256
|
+
display: flex;
|
|
257
|
+
justify-content: space-between;
|
|
258
|
+
align-items: center;
|
|
259
|
+
font-size: 12px;
|
|
260
|
+
color: #9ca3af;
|
|
261
|
+
}
|
|
262
|
+
.figure-version {
|
|
263
|
+
background-color: #6366f1;
|
|
264
|
+
color: white;
|
|
265
|
+
padding: 2px 6px;
|
|
266
|
+
border-radius: 4px;
|
|
267
|
+
font-size: 11px;
|
|
268
|
+
font-weight: 600;
|
|
269
|
+
}
|
|
270
|
+
.empty-state {
|
|
271
|
+
display: flex;
|
|
272
|
+
flex-direction: column;
|
|
273
|
+
align-items: center;
|
|
274
|
+
justify-content: center;
|
|
275
|
+
height: 50vh;
|
|
276
|
+
color: #9ca3af;
|
|
277
|
+
}
|
|
278
|
+
.empty-state p {
|
|
279
|
+
margin: 8px 0;
|
|
280
|
+
}
|
|
281
|
+
.legend {
|
|
282
|
+
display: flex;
|
|
283
|
+
gap: 24px;
|
|
284
|
+
padding: 8px 0;
|
|
285
|
+
}
|
|
286
|
+
.legend-item {
|
|
287
|
+
display: flex;
|
|
288
|
+
align-items: center;
|
|
289
|
+
gap: 6px;
|
|
290
|
+
font-size: 12px;
|
|
291
|
+
}
|
|
292
|
+
.legend-dot {
|
|
293
|
+
width: 10px;
|
|
294
|
+
height: 10px;
|
|
295
|
+
border-radius: 50%;
|
|
296
|
+
}
|
|
297
|
+
.legend-dot.active { background-color: #ef4444; }
|
|
298
|
+
.legend-dot.recent { background-color: #eab308; }
|
|
299
|
+
.legend-dot.older { background-color: #9ca3af; }
|
|
300
|
+
|
|
301
|
+
/* Lightbox */
|
|
302
|
+
.lightbox {
|
|
303
|
+
display: none;
|
|
304
|
+
position: fixed;
|
|
305
|
+
top: 0;
|
|
306
|
+
left: 0;
|
|
307
|
+
width: 100%;
|
|
308
|
+
height: 100%;
|
|
309
|
+
background-color: rgba(0, 0, 0, 0.95);
|
|
310
|
+
z-index: 1000;
|
|
311
|
+
flex-direction: column;
|
|
312
|
+
align-items: center;
|
|
313
|
+
justify-content: center;
|
|
314
|
+
}
|
|
315
|
+
.lightbox.active {
|
|
316
|
+
display: flex;
|
|
317
|
+
}
|
|
318
|
+
.lightbox-content {
|
|
319
|
+
max-width: 90vw;
|
|
320
|
+
max-height: 80vh;
|
|
321
|
+
position: relative;
|
|
322
|
+
}
|
|
323
|
+
.lightbox-content img {
|
|
324
|
+
max-width: 100%;
|
|
325
|
+
max-height: 80vh;
|
|
326
|
+
object-fit: contain;
|
|
327
|
+
}
|
|
328
|
+
.lightbox-close {
|
|
329
|
+
position: absolute;
|
|
330
|
+
top: -40px;
|
|
331
|
+
right: 0;
|
|
332
|
+
background: none;
|
|
333
|
+
border: none;
|
|
334
|
+
color: white;
|
|
335
|
+
font-size: 32px;
|
|
336
|
+
cursor: pointer;
|
|
337
|
+
padding: 8px;
|
|
338
|
+
}
|
|
339
|
+
.lightbox-nav {
|
|
340
|
+
position: absolute;
|
|
341
|
+
top: 50%;
|
|
342
|
+
transform: translateY(-50%);
|
|
343
|
+
background: rgba(255, 255, 255, 0.1);
|
|
344
|
+
border: none;
|
|
345
|
+
color: white;
|
|
346
|
+
font-size: 32px;
|
|
347
|
+
padding: 16px;
|
|
348
|
+
cursor: pointer;
|
|
349
|
+
border-radius: 8px;
|
|
350
|
+
transition: background 0.2s;
|
|
351
|
+
}
|
|
352
|
+
.lightbox-nav:hover {
|
|
353
|
+
background: rgba(255, 255, 255, 0.2);
|
|
354
|
+
}
|
|
355
|
+
.lightbox-prev { left: -60px; }
|
|
356
|
+
.lightbox-next { right: -60px; }
|
|
357
|
+
.lightbox-meta {
|
|
358
|
+
margin-top: 20px;
|
|
359
|
+
text-align: center;
|
|
360
|
+
color: #9ca3af;
|
|
361
|
+
}
|
|
362
|
+
.lightbox-meta .filename {
|
|
363
|
+
font-size: 18px;
|
|
364
|
+
font-weight: 600;
|
|
365
|
+
color: #e5e5e5;
|
|
366
|
+
margin-bottom: 8px;
|
|
367
|
+
}
|
|
368
|
+
.lightbox-meta .details {
|
|
369
|
+
font-size: 14px;
|
|
370
|
+
}
|
|
371
|
+
</style>
|
|
372
|
+
</head>
|
|
373
|
+
<body>
|
|
374
|
+
<div class="header">
|
|
375
|
+
<h1>Figure Viewer</h1>
|
|
376
|
+
<div class="header-controls">
|
|
377
|
+
<input type="text" class="filter-input" placeholder="Filter figures..." id="filterInput">
|
|
378
|
+
<button class="refresh-btn" onclick="location.reload()">Refresh</button>
|
|
379
|
+
</div>
|
|
380
|
+
</div>
|
|
381
|
+
<div class="stats">
|
|
382
|
+
<div class="legend">
|
|
383
|
+
<div class="legend-item"><span class="legend-dot active"></span> Active (<5 min)</div>
|
|
384
|
+
<div class="legend-item"><span class="legend-dot recent"></span> Recent (5 min - 1 hour)</div>
|
|
385
|
+
<div class="legend-item"><span class="legend-dot older"></span> Older (>1 hour)</div>
|
|
386
|
+
</div>
|
|
387
|
+
</div>
|
|
388
|
+
<div class="grid" id="figureGrid">
|
|
389
|
+
${figureCards}
|
|
390
|
+
${emptyMessage}
|
|
391
|
+
</div>
|
|
392
|
+
|
|
393
|
+
<div class="lightbox" id="lightbox">
|
|
394
|
+
<div class="lightbox-content">
|
|
395
|
+
<button class="lightbox-close" onclick="closeLightbox()">×</button>
|
|
396
|
+
<button class="lightbox-nav lightbox-prev" onclick="navigate(-1)">‹</button>
|
|
397
|
+
<img id="lightboxImage" src="" alt="">
|
|
398
|
+
<button class="lightbox-nav lightbox-next" onclick="navigate(1)">›</button>
|
|
399
|
+
</div>
|
|
400
|
+
<div class="lightbox-meta">
|
|
401
|
+
<div class="filename" id="lightboxFilename"></div>
|
|
402
|
+
<div class="details" id="lightboxDetails"></div>
|
|
403
|
+
</div>
|
|
404
|
+
</div>
|
|
405
|
+
|
|
406
|
+
<script>
|
|
407
|
+
const images = ${imagesJson};
|
|
408
|
+
const refreshInterval = ${refreshInterval};
|
|
409
|
+
let currentIndex = 0;
|
|
410
|
+
|
|
411
|
+
// Filter functionality
|
|
412
|
+
const filterInput = document.getElementById('filterInput');
|
|
413
|
+
const figureCards = document.querySelectorAll('.figure-card');
|
|
414
|
+
|
|
415
|
+
filterInput.addEventListener('input', (e) => {
|
|
416
|
+
const query = e.target.value.toLowerCase();
|
|
417
|
+
figureCards.forEach(card => {
|
|
418
|
+
const name = card.dataset.name.toLowerCase();
|
|
419
|
+
card.style.display = name.includes(query) ? 'block' : 'none';
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
// Lightbox functionality
|
|
424
|
+
function openLightbox(index) {
|
|
425
|
+
currentIndex = index;
|
|
426
|
+
showImage();
|
|
427
|
+
document.getElementById('lightbox').classList.add('active');
|
|
428
|
+
document.body.style.overflow = 'hidden';
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function closeLightbox() {
|
|
432
|
+
document.getElementById('lightbox').classList.remove('active');
|
|
433
|
+
document.body.style.overflow = '';
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function navigate(direction) {
|
|
437
|
+
currentIndex += direction;
|
|
438
|
+
if (currentIndex < 0) currentIndex = images.length - 1;
|
|
439
|
+
if (currentIndex >= images.length) currentIndex = 0;
|
|
440
|
+
showImage();
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function showImage() {
|
|
444
|
+
const img = images[currentIndex];
|
|
445
|
+
document.getElementById('lightboxImage').src = img.path;
|
|
446
|
+
document.getElementById('lightboxFilename').textContent = img.name;
|
|
447
|
+
|
|
448
|
+
const size = ${images.length} > 0 ? formatFileSize(images[currentIndex].size) : '';
|
|
449
|
+
document.getElementById('lightboxDetails').textContent = size;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function formatFileSize(bytes) {
|
|
453
|
+
if (bytes < 1024) return bytes + ' B';
|
|
454
|
+
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
|
455
|
+
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Keyboard navigation
|
|
459
|
+
document.addEventListener('keydown', (e) => {
|
|
460
|
+
const lightbox = document.getElementById('lightbox');
|
|
461
|
+
if (!lightbox.classList.contains('active')) return;
|
|
462
|
+
|
|
463
|
+
if (e.key === 'Escape') closeLightbox();
|
|
464
|
+
if (e.key === 'ArrowLeft') navigate(-1);
|
|
465
|
+
if (e.key === 'ArrowRight') navigate(1);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
// Click outside to close
|
|
469
|
+
document.getElementById('lightbox').addEventListener('click', (e) => {
|
|
470
|
+
if (e.target.id === 'lightbox') closeLightbox();
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
// Auto-refresh every 30 seconds
|
|
474
|
+
setInterval(() => {
|
|
475
|
+
location.reload();
|
|
476
|
+
}, ${refreshInterval});
|
|
477
|
+
</script>
|
|
478
|
+
</body>
|
|
479
|
+
</html>`;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
module.exports = {
|
|
483
|
+
generateHtml,
|
|
484
|
+
getImages,
|
|
485
|
+
SUPPORTED_EXTENSIONS
|
|
486
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const chokidar = require('chokidar');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
const projectRoot = process.argv[2]; // Now this is the output directory
|
|
8
|
+
const figuresDir = process.argv[3];
|
|
9
|
+
const outputPath = process.argv[4];
|
|
10
|
+
const refreshInterval = parseInt(process.argv[5]) || 30000;
|
|
11
|
+
const watcherTimeout = parseInt(process.argv[6]) || 3600000;
|
|
12
|
+
const pidFile = path.join(projectRoot, '.figure-viewer.pid');
|
|
13
|
+
|
|
14
|
+
let lastActivity = Date.now();
|
|
15
|
+
|
|
16
|
+
function generateAndSave() {
|
|
17
|
+
// Require fresh each time to get updated modules
|
|
18
|
+
delete require.cache[require.resolve(path.join(__dirname, '..', 'lib/html'))];
|
|
19
|
+
delete require.cache[require.resolve(path.join(__dirname, '..', 'lib/history'))];
|
|
20
|
+
delete require.cache[require.resolve(path.join(__dirname, '..', 'lib/config'))];
|
|
21
|
+
|
|
22
|
+
const { generateHtml, getImages } = require(path.join(__dirname, '..', 'lib/html'));
|
|
23
|
+
const { updateHistory } = require(path.join(__dirname, '..', 'lib/history'));
|
|
24
|
+
const { getConfig } = require(path.join(__dirname, '..', 'lib/config'));
|
|
25
|
+
|
|
26
|
+
const config = getConfig();
|
|
27
|
+
config.refreshInterval = refreshInterval;
|
|
28
|
+
|
|
29
|
+
const images = getImages(figuresDir);
|
|
30
|
+
const history = updateHistory(figuresDir, images);
|
|
31
|
+
const html = generateHtml(figuresDir, { history, config });
|
|
32
|
+
fs.writeFileSync(outputPath, html);
|
|
33
|
+
console.log('Regenerated at', new Date().toLocaleTimeString(), `(${images.length} figures)`);
|
|
34
|
+
|
|
35
|
+
lastActivity = Date.now();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Save PID and metadata
|
|
39
|
+
fs.writeFileSync(pidFile, JSON.stringify({
|
|
40
|
+
pid: process.pid,
|
|
41
|
+
startTime: Date.now(),
|
|
42
|
+
figuresDir,
|
|
43
|
+
outputPath
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
// Cleanup on exit
|
|
47
|
+
process.on('exit', () => {
|
|
48
|
+
if (fs.existsSync(pidFile)) fs.unlinkSync(pidFile);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
process.on('SIGINT', () => {
|
|
52
|
+
if (fs.existsSync(pidFile)) fs.unlinkSync(pidFile);
|
|
53
|
+
process.exit(0);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
process.on('SIGTERM', () => {
|
|
57
|
+
if (fs.existsSync(pidFile)) fs.unlinkSync(pidFile);
|
|
58
|
+
process.exit(0);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Check for inactivity timeout
|
|
62
|
+
const inactivityCheck = setInterval(() => {
|
|
63
|
+
const idleTime = Date.now() - lastActivity;
|
|
64
|
+
if (idleTime > watcherTimeout) {
|
|
65
|
+
console.log(`No activity for ${Math.round(idleTime/60000)} minutes. Stopping watcher...`);
|
|
66
|
+
clearInterval(inactivityCheck);
|
|
67
|
+
if (fs.existsSync(pidFile)) fs.unlinkSync(pidFile);
|
|
68
|
+
process.exit(0);
|
|
69
|
+
}
|
|
70
|
+
}, 60000); // Check every minute
|
|
71
|
+
|
|
72
|
+
const watcher = chokidar.watch(figuresDir, {
|
|
73
|
+
ignored: /(^|[\/\\])\../,
|
|
74
|
+
persistent: true,
|
|
75
|
+
ignoreInitial: true,
|
|
76
|
+
awaitWriteFinish: { stabilityThreshold: 500, pollInterval: 100 }
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
watcher.on('add', (file) => {
|
|
80
|
+
console.log('New figure:', path.basename(file));
|
|
81
|
+
generateAndSave();
|
|
82
|
+
});
|
|
83
|
+
watcher.on('change', (file) => {
|
|
84
|
+
console.log('Changed:', path.basename(file));
|
|
85
|
+
generateAndSave();
|
|
86
|
+
});
|
|
87
|
+
watcher.on('unlink', (file) => {
|
|
88
|
+
console.log('Removed:', path.basename(file));
|
|
89
|
+
generateAndSave();
|
|
90
|
+
});
|
|
91
|
+
watcher.on('error', (error) => {
|
|
92
|
+
console.error('Watcher error:', error.message);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
console.log('Watching', figuresDir, 'for changes... (PID:', process.pid + ')');
|
|
96
|
+
console.log('Will stop after', Math.round(watcherTimeout/60000), 'minutes of inactivity');
|