pulp-image 0.1.8 → 0.1.9
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/CHANGELOG.md +49 -11
- package/ROADMAP.md +2 -1
- package/bin/pulp.js +60 -2
- package/package.json +1 -1
- package/src/uiServer.js +18 -0
- package/src/updateCheck.js +200 -0
- package/ui/app.js +56 -0
- package/ui/styles.css +128 -0
package/CHANGELOG.md
CHANGED
|
@@ -17,36 +17,74 @@ See [Roadmap](ROADMAP.md) for upcoming features (v0.2.0: UI Redesign, Rotate, Fl
|
|
|
17
17
|
### Changed
|
|
18
18
|
- Streamlined README with quick links and features grid
|
|
19
19
|
- Added public roadmap and changelog
|
|
20
|
-
-
|
|
20
|
+
- Improved documentation and help content
|
|
21
21
|
|
|
22
22
|
---
|
|
23
23
|
|
|
24
24
|
## [0.1.7] - 2026-01-12
|
|
25
25
|
|
|
26
|
-
###
|
|
27
|
-
-
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
### Fixed
|
|
27
|
+
- Custom output directory filter not working correctly in UI
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
- Improved Help tab content and styling
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## [0.1.6] - 2026-01-11
|
|
35
|
+
|
|
36
|
+
### Changed
|
|
37
|
+
- Updated README and package.json homepage
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## [0.1.5] - 2026-01-11
|
|
42
|
+
|
|
43
|
+
### Fixed
|
|
44
|
+
- UI color contrast for WCAG AA accessibility
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## [0.1.4] - 2026-01-10
|
|
49
|
+
|
|
50
|
+
### Fixed
|
|
51
|
+
- Terminal banner (ASCII art) width now dynamic
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## [0.1.3] - 2026-01-10
|
|
56
|
+
|
|
57
|
+
### Fixed
|
|
58
|
+
- Dynamic version display in banner
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## [0.1.2] - 2026-01-09
|
|
30
63
|
|
|
31
64
|
### Fixed
|
|
32
|
-
-
|
|
65
|
+
- npm bin configuration for global install
|
|
33
66
|
|
|
34
67
|
---
|
|
35
68
|
|
|
36
|
-
## [0.1.
|
|
69
|
+
## [0.1.1] - 2026-01-09 (Initial Release)
|
|
37
70
|
|
|
38
71
|
### Added
|
|
39
72
|
- **CLI Tool**: Full command-line interface for image processing
|
|
40
73
|
- **Browser UI**: Launch with `pulp ui`, works offline, no uploads
|
|
41
|
-
- **Format Conversion**: PNG, JPG, WebP, AVIF
|
|
42
|
-
- **Resize**: By width, height, or exact dimensions
|
|
43
|
-
- **Quality Control**: Compression quality 1-100
|
|
74
|
+
- **Format Conversion**: PNG, JPG, WebP, AVIF
|
|
75
|
+
- **Resize**: By width, height, or exact dimensions with aspect ratio preservation
|
|
76
|
+
- **Quality Control**: Compression quality 1-100 for lossy formats
|
|
44
77
|
- **Lossless Mode**: For WebP and AVIF
|
|
45
78
|
- **Transparency Handling**: Flatten with custom background color
|
|
46
|
-
- **Batch Processing**: Process entire folders
|
|
79
|
+
- **Batch Processing**: Process entire folders at once
|
|
80
|
+
- **Auto Suffix**: Add dimensions to filenames (-800w, -600h, -800x600)
|
|
81
|
+
- **Custom Suffix**: Add custom text to output filenames
|
|
82
|
+
- **Rename Patterns**: Tokens {name}, {ext}, {index} for output naming (UI only)
|
|
47
83
|
- **Custom Output Directory**: Save files anywhere
|
|
48
84
|
- **Overwrite Protection**: Skip or overwrite existing files
|
|
49
85
|
- **Delete Originals**: Remove source files after processing (CLI only)
|
|
86
|
+
- **Verbose Mode**: Detailed per-file output (CLI only)
|
|
87
|
+
- **Statistics**: Original size, final size, bytes saved, percentage reduction
|
|
50
88
|
|
|
51
89
|
---
|
|
52
90
|
|
package/ROADMAP.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
| Feature | Description |
|
|
10
10
|
|---------|-------------|
|
|
11
|
-
| ~~**Format Conversion**~~ | ~~Convert between PNG, JPG, WebP, AVIF
|
|
11
|
+
| ~~**Format Conversion**~~ | ~~Convert between PNG, JPG, WebP, AVIF~~ |
|
|
12
12
|
| ~~**Resize**~~ | ~~Resize by width, height, or both with auto aspect ratio~~ |
|
|
13
13
|
| ~~**Quality Control**~~ | ~~Adjust compression quality (1-100)~~ |
|
|
14
14
|
| ~~**Lossless Mode**~~ | ~~Preserve full quality for WebP and AVIF~~ |
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
| Feature | Description |
|
|
28
28
|
|---------|-------------|
|
|
29
29
|
| **UI Redesign** | Modern tabbed interface with dark mode |
|
|
30
|
+
| **Results Redesign** | Clearer output with operation summary (CLI and UI) |
|
|
30
31
|
| **Rotate** | Rotate images by 90°, 180°, 270°, or custom angle |
|
|
31
32
|
| **Flip / Mirror** | Flip vertically or horizontally |
|
|
32
33
|
| **Grayscale** | Convert images to black and white |
|
package/bin/pulp.js
CHANGED
|
@@ -19,15 +19,33 @@ import { planTasks } from '../src/planTasks.js';
|
|
|
19
19
|
import { Reporter } from '../src/reporter.js';
|
|
20
20
|
import { runJob } from '../src/runJob.js';
|
|
21
21
|
import { startUIServer } from '../src/uiServer.js';
|
|
22
|
+
import { checkForUpdate, formatUpdateMessage } from '../src/updateCheck.js';
|
|
22
23
|
import { statSync, existsSync } from 'fs';
|
|
23
24
|
|
|
24
25
|
const program = new Command();
|
|
25
26
|
const banner = getBanner(pkg.version);
|
|
26
27
|
|
|
28
|
+
// Format update message with colors for CLI
|
|
29
|
+
function formatUpdateBox(updateMsg) {
|
|
30
|
+
if (!updateMsg) return null;
|
|
31
|
+
|
|
32
|
+
const lines = [
|
|
33
|
+
'',
|
|
34
|
+
chalk.bgYellow.black(' UPDATE AVAILABLE '),
|
|
35
|
+
'',
|
|
36
|
+
` ${chalk.gray('Current:')} ${chalk.white(updateMsg.current)}`,
|
|
37
|
+
` ${chalk.gray('Latest:')} ${chalk.green.bold(updateMsg.latest)}`,
|
|
38
|
+
'',
|
|
39
|
+
` ${chalk.cyan('npm update -g pulp-image')}`,
|
|
40
|
+
''
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
return lines.join('\n');
|
|
44
|
+
}
|
|
45
|
+
|
|
27
46
|
program
|
|
28
47
|
.name('pulp')
|
|
29
48
|
.description('Full-featured image processing CLI with a browser UI. 100% local.')
|
|
30
|
-
.version(pkg.version)
|
|
31
49
|
.addHelpText('before', chalk.cyan(banner))
|
|
32
50
|
.argument('[input]', 'Input file or directory')
|
|
33
51
|
.option('-w, --width <number>', 'Output width in pixels')
|
|
@@ -73,6 +91,9 @@ Compression Behavior:
|
|
|
73
91
|
// Display banner
|
|
74
92
|
console.log(chalk.cyan(banner));
|
|
75
93
|
|
|
94
|
+
// Start update check early (runs in background while processing)
|
|
95
|
+
const updateCheckPromise = checkForUpdate(pkg.version).catch(() => null);
|
|
96
|
+
|
|
76
97
|
// Normalize config
|
|
77
98
|
const config = {
|
|
78
99
|
input: input || null,
|
|
@@ -212,6 +233,15 @@ Compression Behavior:
|
|
|
212
233
|
results.failed.forEach(f => reporter.recordFailed(f.filePath, new Error(f.error)));
|
|
213
234
|
reporter.printSummary(config.verbose);
|
|
214
235
|
}
|
|
236
|
+
|
|
237
|
+
// Show update notification at the end (if available)
|
|
238
|
+
const updateInfo = await updateCheckPromise;
|
|
239
|
+
if (updateInfo) {
|
|
240
|
+
const updateMsg = formatUpdateMessage(updateInfo);
|
|
241
|
+
if (updateMsg) {
|
|
242
|
+
console.log(formatUpdateBox(updateMsg));
|
|
243
|
+
}
|
|
244
|
+
}
|
|
215
245
|
});
|
|
216
246
|
|
|
217
247
|
// UI command
|
|
@@ -227,6 +257,14 @@ program
|
|
|
227
257
|
process.exit(1);
|
|
228
258
|
}
|
|
229
259
|
|
|
260
|
+
// Check for updates when starting UI
|
|
261
|
+
checkForUpdate(pkg.version).then(updateInfo => {
|
|
262
|
+
const updateMsg = formatUpdateMessage(updateInfo);
|
|
263
|
+
if (updateMsg) {
|
|
264
|
+
console.log(formatUpdateBox(updateMsg));
|
|
265
|
+
}
|
|
266
|
+
}).catch(() => {}); // Silently ignore errors
|
|
267
|
+
|
|
230
268
|
try {
|
|
231
269
|
const { server } = await startUIServer(port);
|
|
232
270
|
|
|
@@ -248,5 +286,25 @@ program
|
|
|
248
286
|
}
|
|
249
287
|
});
|
|
250
288
|
|
|
251
|
-
|
|
289
|
+
// Handle --version manually (before Commander parses) to include update check
|
|
290
|
+
if (process.argv.includes('--version') || process.argv.includes('-V')) {
|
|
291
|
+
(async () => {
|
|
292
|
+
console.log(pkg.version);
|
|
293
|
+
|
|
294
|
+
// Check for updates
|
|
295
|
+
try {
|
|
296
|
+
const updateInfo = await checkForUpdate(pkg.version);
|
|
297
|
+
const updateMsg = formatUpdateMessage(updateInfo);
|
|
298
|
+
if (updateMsg) {
|
|
299
|
+
console.log(formatUpdateBox(updateMsg));
|
|
300
|
+
}
|
|
301
|
+
} catch {
|
|
302
|
+
// Silently ignore errors
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
process.exit(0);
|
|
306
|
+
})();
|
|
307
|
+
} else {
|
|
308
|
+
program.parse();
|
|
309
|
+
}
|
|
252
310
|
|
package/package.json
CHANGED
package/src/uiServer.js
CHANGED
|
@@ -7,6 +7,7 @@ import { randomUUID } from 'crypto';
|
|
|
7
7
|
import multer from 'multer';
|
|
8
8
|
import open from 'open';
|
|
9
9
|
import { runJob } from './runJob.js';
|
|
10
|
+
import { checkForUpdate } from './updateCheck.js';
|
|
10
11
|
import { exec, spawn } from 'child_process';
|
|
11
12
|
import { promisify } from 'util';
|
|
12
13
|
|
|
@@ -321,6 +322,23 @@ export async function startUIServer(port = 3000) {
|
|
|
321
322
|
res.status(500).json({ error: 'Failed to read version' });
|
|
322
323
|
}
|
|
323
324
|
});
|
|
325
|
+
|
|
326
|
+
// Update check endpoint - checks npm registry for newer versions
|
|
327
|
+
app.get('/api/check-update', async (req, res) => {
|
|
328
|
+
try {
|
|
329
|
+
const packageJsonPath = join(projectRoot, 'package.json');
|
|
330
|
+
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
|
|
331
|
+
const currentVersion = packageJson.version;
|
|
332
|
+
|
|
333
|
+
const force = req.query.force === 'true';
|
|
334
|
+
const updateInfo = await checkForUpdate(currentVersion, { force });
|
|
335
|
+
|
|
336
|
+
res.json(updateInfo);
|
|
337
|
+
} catch (error) {
|
|
338
|
+
console.error('Error checking for updates:', error);
|
|
339
|
+
res.status(500).json({ error: 'Failed to check for updates' });
|
|
340
|
+
}
|
|
341
|
+
});
|
|
324
342
|
|
|
325
343
|
// Serve static files from ui directory
|
|
326
344
|
app.use(express.static(uiDir));
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Update checker for Pulp Image
|
|
3
|
+
* Checks npm registry for newer versions and caches results
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
|
7
|
+
import { join } from 'path';
|
|
8
|
+
import { homedir } from 'os';
|
|
9
|
+
|
|
10
|
+
const PACKAGE_NAME = 'pulp-image';
|
|
11
|
+
const NPM_REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
|
|
12
|
+
const CACHE_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Get cache directory path
|
|
16
|
+
* @returns {string} Path to cache directory
|
|
17
|
+
*/
|
|
18
|
+
function getCacheDir() {
|
|
19
|
+
return join(homedir(), '.config', 'pulp-image');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Get cache file path
|
|
24
|
+
* @returns {string} Path to cache file
|
|
25
|
+
*/
|
|
26
|
+
function getCacheFile() {
|
|
27
|
+
return join(getCacheDir(), 'update-cache.json');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Read cached update check result
|
|
32
|
+
* @returns {object|null} Cached result or null if expired/missing
|
|
33
|
+
*/
|
|
34
|
+
function readCache() {
|
|
35
|
+
try {
|
|
36
|
+
const cacheFile = getCacheFile();
|
|
37
|
+
if (!existsSync(cacheFile)) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const cache = JSON.parse(readFileSync(cacheFile, 'utf-8'));
|
|
42
|
+
const now = Date.now();
|
|
43
|
+
|
|
44
|
+
// Check if cache is still valid
|
|
45
|
+
if (cache.timestamp && (now - cache.timestamp) < CACHE_DURATION_MS) {
|
|
46
|
+
return cache;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return null; // Cache expired
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Write update check result to cache
|
|
57
|
+
* @param {object} data - Data to cache
|
|
58
|
+
*/
|
|
59
|
+
function writeCache(data) {
|
|
60
|
+
try {
|
|
61
|
+
const cacheDir = getCacheDir();
|
|
62
|
+
if (!existsSync(cacheDir)) {
|
|
63
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const cache = {
|
|
67
|
+
...data,
|
|
68
|
+
timestamp: Date.now()
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
writeFileSync(getCacheFile(), JSON.stringify(cache, null, 2));
|
|
72
|
+
} catch {
|
|
73
|
+
// Silently fail - caching is optional
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Compare two semantic version strings
|
|
79
|
+
* @param {string} current - Current version (e.g., "0.1.8")
|
|
80
|
+
* @param {string} latest - Latest version (e.g., "0.2.0")
|
|
81
|
+
* @returns {boolean} True if latest is newer than current
|
|
82
|
+
*/
|
|
83
|
+
function isNewerVersion(current, latest) {
|
|
84
|
+
const parseVersion = (v) => v.replace(/^v/, '').split('.').map(n => parseInt(n, 10) || 0);
|
|
85
|
+
|
|
86
|
+
const currentParts = parseVersion(current);
|
|
87
|
+
const latestParts = parseVersion(latest);
|
|
88
|
+
|
|
89
|
+
for (let i = 0; i < 3; i++) {
|
|
90
|
+
const curr = currentParts[i] || 0;
|
|
91
|
+
const lat = latestParts[i] || 0;
|
|
92
|
+
|
|
93
|
+
if (lat > curr) return true;
|
|
94
|
+
if (lat < curr) return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return false; // Versions are equal
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Fetch latest version from npm registry
|
|
102
|
+
* @param {number} timeoutMs - Request timeout in milliseconds
|
|
103
|
+
* @returns {Promise<string|null>} Latest version or null on error
|
|
104
|
+
*/
|
|
105
|
+
async function fetchLatestVersion(timeoutMs = 3000) {
|
|
106
|
+
try {
|
|
107
|
+
const controller = new AbortController();
|
|
108
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
109
|
+
|
|
110
|
+
const response = await fetch(NPM_REGISTRY_URL, {
|
|
111
|
+
signal: controller.signal,
|
|
112
|
+
headers: {
|
|
113
|
+
'Accept': 'application/json'
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
clearTimeout(timeout);
|
|
118
|
+
|
|
119
|
+
if (!response.ok) {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const data = await response.json();
|
|
124
|
+
return data.version || null;
|
|
125
|
+
} catch {
|
|
126
|
+
// Network error, timeout, or offline - silently fail
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Check for updates (with caching)
|
|
133
|
+
* @param {string} currentVersion - Current installed version
|
|
134
|
+
* @param {object} options - Options
|
|
135
|
+
* @param {boolean} options.force - Force check, ignore cache
|
|
136
|
+
* @returns {Promise<object>} Update check result
|
|
137
|
+
*/
|
|
138
|
+
export async function checkForUpdate(currentVersion, options = {}) {
|
|
139
|
+
const { force = false } = options;
|
|
140
|
+
|
|
141
|
+
// Check cache first (unless forced)
|
|
142
|
+
if (!force) {
|
|
143
|
+
const cached = readCache();
|
|
144
|
+
if (cached && cached.currentVersion === currentVersion) {
|
|
145
|
+
return {
|
|
146
|
+
updateAvailable: cached.updateAvailable,
|
|
147
|
+
currentVersion: cached.currentVersion,
|
|
148
|
+
latestVersion: cached.latestVersion,
|
|
149
|
+
cached: true
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Fetch latest version from npm
|
|
155
|
+
const latestVersion = await fetchLatestVersion();
|
|
156
|
+
|
|
157
|
+
if (!latestVersion) {
|
|
158
|
+
// Couldn't fetch - return no update (fail silently)
|
|
159
|
+
return {
|
|
160
|
+
updateAvailable: false,
|
|
161
|
+
currentVersion,
|
|
162
|
+
latestVersion: null,
|
|
163
|
+
error: true
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const updateAvailable = isNewerVersion(currentVersion, latestVersion);
|
|
168
|
+
|
|
169
|
+
// Cache the result
|
|
170
|
+
const result = {
|
|
171
|
+
updateAvailable,
|
|
172
|
+
currentVersion,
|
|
173
|
+
latestVersion
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
writeCache(result);
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
...result,
|
|
180
|
+
cached: false
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Format update notification message for CLI
|
|
186
|
+
* @param {object} updateInfo - Result from checkForUpdate
|
|
187
|
+
* @returns {object|null} Message parts or null if no update
|
|
188
|
+
*/
|
|
189
|
+
export function formatUpdateMessage(updateInfo) {
|
|
190
|
+
if (!updateInfo.updateAvailable || !updateInfo.latestVersion) {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
current: updateInfo.currentVersion,
|
|
196
|
+
latest: updateInfo.latestVersion,
|
|
197
|
+
// Simple text version for basic usage
|
|
198
|
+
text: `Update available: ${updateInfo.currentVersion} → ${updateInfo.latestVersion}\nRun: npm update -g pulp-image`
|
|
199
|
+
};
|
|
200
|
+
}
|
package/ui/app.js
CHANGED
|
@@ -67,6 +67,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|
|
67
67
|
await loadVersion(); // Load version from backend
|
|
68
68
|
await updateOutputDirectory(); // Initialize output directory
|
|
69
69
|
await updateUI();
|
|
70
|
+
checkForUpdates(); // Check for updates (non-blocking)
|
|
70
71
|
});
|
|
71
72
|
|
|
72
73
|
// Load version from backend (single source of truth)
|
|
@@ -1316,6 +1317,61 @@ function setupHelpSubnav() {
|
|
|
1316
1317
|
window.addEventListener('scroll', updateActiveOnScroll);
|
|
1317
1318
|
}
|
|
1318
1319
|
|
|
1320
|
+
// Check for Updates
|
|
1321
|
+
async function checkForUpdates() {
|
|
1322
|
+
try {
|
|
1323
|
+
const response = await fetch('/api/check-update');
|
|
1324
|
+
if (!response.ok) return;
|
|
1325
|
+
|
|
1326
|
+
const updateInfo = await response.json();
|
|
1327
|
+
|
|
1328
|
+
if (updateInfo.updateAvailable && updateInfo.latestVersion) {
|
|
1329
|
+
showUpdateBanner(updateInfo.currentVersion, updateInfo.latestVersion);
|
|
1330
|
+
}
|
|
1331
|
+
} catch (error) {
|
|
1332
|
+
// Silently fail - update check is optional
|
|
1333
|
+
console.debug('Update check failed:', error);
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
// Show Update Banner
|
|
1338
|
+
function showUpdateBanner(currentVersion, latestVersion) {
|
|
1339
|
+
// Don't show if already shown
|
|
1340
|
+
if (document.getElementById('update-banner')) return;
|
|
1341
|
+
|
|
1342
|
+
const banner = document.createElement('div');
|
|
1343
|
+
banner.id = 'update-banner';
|
|
1344
|
+
banner.className = 'update-banner';
|
|
1345
|
+
banner.innerHTML = `
|
|
1346
|
+
<div class="update-banner-content">
|
|
1347
|
+
<span class="update-banner-icon">🎉</span>
|
|
1348
|
+
<div class="update-banner-text">
|
|
1349
|
+
<strong>Update available!</strong>
|
|
1350
|
+
<span class="update-banner-versions">v${currentVersion} → v${latestVersion}</span>
|
|
1351
|
+
</div>
|
|
1352
|
+
<div class="update-banner-actions">
|
|
1353
|
+
<span class="update-banner-hint">CLI users:</span>
|
|
1354
|
+
<code>npm update -g pulp-image</code>
|
|
1355
|
+
<span class="update-banner-separator">|</span>
|
|
1356
|
+
<span class="update-banner-hint">Portable:</span>
|
|
1357
|
+
<a href="https://pulp.run" target="_blank" rel="noopener">pulp.run</a>
|
|
1358
|
+
</div>
|
|
1359
|
+
</div>
|
|
1360
|
+
<button class="update-banner-close" aria-label="Dismiss">×</button>
|
|
1361
|
+
`;
|
|
1362
|
+
|
|
1363
|
+
// Add close handler
|
|
1364
|
+
banner.querySelector('.update-banner-close').addEventListener('click', () => {
|
|
1365
|
+
banner.remove();
|
|
1366
|
+
});
|
|
1367
|
+
|
|
1368
|
+
// Insert at top of container
|
|
1369
|
+
const container = document.querySelector('.container');
|
|
1370
|
+
if (container) {
|
|
1371
|
+
container.insertBefore(banner, container.firstChild);
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1319
1375
|
// Initialize when DOM is ready
|
|
1320
1376
|
document.addEventListener('DOMContentLoaded', () => {
|
|
1321
1377
|
setupTerminalCopyButtons();
|
package/ui/styles.css
CHANGED
|
@@ -1220,3 +1220,131 @@ body {
|
|
|
1220
1220
|
}
|
|
1221
1221
|
}
|
|
1222
1222
|
|
|
1223
|
+
/* Update Banner */
|
|
1224
|
+
.update-banner {
|
|
1225
|
+
background: linear-gradient(135deg, #2e7d32 0%, #388e3c 100%);
|
|
1226
|
+
padding: 1rem 1.25rem;
|
|
1227
|
+
display: flex;
|
|
1228
|
+
align-items: center;
|
|
1229
|
+
justify-content: space-between;
|
|
1230
|
+
gap: 1rem;
|
|
1231
|
+
border-radius: var(--border-radius-lg) var(--border-radius-lg) 0 0;
|
|
1232
|
+
box-shadow: 0 2px 8px rgba(46, 125, 50, 0.3);
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
.update-banner-content {
|
|
1236
|
+
display: flex;
|
|
1237
|
+
align-items: center;
|
|
1238
|
+
gap: 1rem;
|
|
1239
|
+
flex-wrap: wrap;
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
.update-banner-icon {
|
|
1243
|
+
font-size: 1.5rem;
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
.update-banner-text {
|
|
1247
|
+
display: flex;
|
|
1248
|
+
flex-direction: column;
|
|
1249
|
+
gap: 0.15rem;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
.update-banner-text strong {
|
|
1253
|
+
color: white;
|
|
1254
|
+
font-size: 1rem;
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
.update-banner-versions {
|
|
1258
|
+
color: rgba(255, 255, 255, 0.9);
|
|
1259
|
+
font-size: 0.9rem;
|
|
1260
|
+
font-weight: 500;
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
.update-banner-actions {
|
|
1264
|
+
display: flex;
|
|
1265
|
+
align-items: center;
|
|
1266
|
+
gap: 0.5rem;
|
|
1267
|
+
flex-wrap: wrap;
|
|
1268
|
+
color: rgba(255, 255, 255, 0.85);
|
|
1269
|
+
font-size: 0.85rem;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
.update-banner-hint {
|
|
1273
|
+
color: rgba(255, 255, 255, 0.7);
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
.update-banner-separator {
|
|
1277
|
+
color: rgba(255, 255, 255, 0.4);
|
|
1278
|
+
margin: 0 0.25rem;
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
.update-banner-actions code {
|
|
1282
|
+
background: rgba(255, 255, 255, 0.2);
|
|
1283
|
+
color: white;
|
|
1284
|
+
padding: 0.25rem 0.5rem;
|
|
1285
|
+
border-radius: 4px;
|
|
1286
|
+
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
|
1287
|
+
font-size: 0.8rem;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
.update-banner-actions a {
|
|
1291
|
+
color: white;
|
|
1292
|
+
font-weight: 600;
|
|
1293
|
+
text-decoration: underline;
|
|
1294
|
+
text-decoration-thickness: 2px;
|
|
1295
|
+
text-underline-offset: 2px;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
.update-banner-actions a:hover {
|
|
1299
|
+
text-decoration-thickness: 3px;
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
.update-banner-close {
|
|
1303
|
+
background: rgba(255, 255, 255, 0.2);
|
|
1304
|
+
border: none;
|
|
1305
|
+
font-size: 1.25rem;
|
|
1306
|
+
color: white;
|
|
1307
|
+
cursor: pointer;
|
|
1308
|
+
padding: 0.25rem 0.5rem;
|
|
1309
|
+
line-height: 1;
|
|
1310
|
+
border-radius: 4px;
|
|
1311
|
+
opacity: 0.8;
|
|
1312
|
+
transition: opacity 0.2s, background 0.2s;
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
.update-banner-close:hover {
|
|
1316
|
+
opacity: 1;
|
|
1317
|
+
background: rgba(255, 255, 255, 0.3);
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
@media (max-width: 700px) {
|
|
1321
|
+
.update-banner {
|
|
1322
|
+
flex-direction: column;
|
|
1323
|
+
align-items: flex-start;
|
|
1324
|
+
gap: 0.75rem;
|
|
1325
|
+
position: relative;
|
|
1326
|
+
padding-right: 3rem;
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
.update-banner-content {
|
|
1330
|
+
flex-direction: column;
|
|
1331
|
+
align-items: flex-start;
|
|
1332
|
+
gap: 0.5rem;
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
.update-banner-close {
|
|
1336
|
+
position: absolute;
|
|
1337
|
+
top: 0.75rem;
|
|
1338
|
+
right: 0.75rem;
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
.update-banner-actions {
|
|
1342
|
+
flex-direction: column;
|
|
1343
|
+
align-items: flex-start;
|
|
1344
|
+
gap: 0.35rem;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
.update-banner-separator {
|
|
1348
|
+
display: none;
|
|
1349
|
+
}
|
|
1350
|
+
}
|