repoimage 0.2.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.
Files changed (71) hide show
  1. package/.claude/settings.local.json +8 -0
  2. package/AGENTS.md +28 -0
  3. package/PROJECT-AGENTS.md +55 -0
  4. package/README.md +153 -0
  5. package/TODO.md +132 -0
  6. package/client/index.html +12 -0
  7. package/client/package.json +23 -0
  8. package/client/src/App.tsx +599 -0
  9. package/client/src/components/FsBrowser.tsx +210 -0
  10. package/client/src/components/Settings.tsx +81 -0
  11. package/client/src/index.css +797 -0
  12. package/client/src/lib/api.ts +69 -0
  13. package/client/src/lib/collect.ts +204 -0
  14. package/client/src/lib/format.ts +96 -0
  15. package/client/src/lib/session.ts +58 -0
  16. package/client/src/main.tsx +10 -0
  17. package/client/src/vite-env.d.ts +1 -0
  18. package/client/tsconfig.json +18 -0
  19. package/client/vite.config.ts +27 -0
  20. package/docs/README.md +28 -0
  21. package/docs/api/overview.md +65 -0
  22. package/docs/api/scan.md +188 -0
  23. package/docs/architecture.md +155 -0
  24. package/docs/design/invariants.md +19 -0
  25. package/docs/design/role-system.md +50 -0
  26. package/docs/development/README.md +94 -0
  27. package/docs/features/README.md +21 -0
  28. package/docs/features/compression-score.md +75 -0
  29. package/docs/features/exclusions.md +63 -0
  30. package/docs/features/session.md +64 -0
  31. package/package.json +37 -0
  32. package/server/dist/cli.d.ts +3 -0
  33. package/server/dist/cli.d.ts.map +1 -0
  34. package/server/dist/cli.js +54 -0
  35. package/server/dist/cli.js.map +1 -0
  36. package/server/dist/fs-list.d.ts +3 -0
  37. package/server/dist/fs-list.d.ts.map +1 -0
  38. package/server/dist/fs-list.js +73 -0
  39. package/server/dist/fs-list.js.map +1 -0
  40. package/server/dist/paths.d.ts +3 -0
  41. package/server/dist/paths.d.ts.map +1 -0
  42. package/server/dist/paths.js +12 -0
  43. package/server/dist/paths.js.map +1 -0
  44. package/server/dist/scan.d.ts +16 -0
  45. package/server/dist/scan.d.ts.map +1 -0
  46. package/server/dist/scan.js +158 -0
  47. package/server/dist/scan.js.map +1 -0
  48. package/server/dist/server.d.ts +6 -0
  49. package/server/dist/server.d.ts.map +1 -0
  50. package/server/dist/server.js +313 -0
  51. package/server/dist/server.js.map +1 -0
  52. package/server/package.json +22 -0
  53. package/server/src/cli.ts +63 -0
  54. package/server/src/fs-list.ts +70 -0
  55. package/server/src/paths.ts +6 -0
  56. package/server/src/scan.ts +203 -0
  57. package/server/src/server.ts +356 -0
  58. package/server/tsconfig.json +9 -0
  59. package/shared/package.json +10 -0
  60. package/shared/src/constants.ts +37 -0
  61. package/shared/src/index.ts +4 -0
  62. package/shared/src/role-guess.ts +103 -0
  63. package/shared/src/schema.ts +18 -0
  64. package/shared/src/types.ts +36 -0
  65. package/shared/tsconfig.json +9 -0
  66. package/test/cli.test.js +56 -0
  67. package/test/fs-list.test.js +39 -0
  68. package/test/role-guess.test.js +50 -0
  69. package/test/scan.test.js +150 -0
  70. package/test/server.test.js +308 -0
  71. package/tsconfig.base.json +14 -0
@@ -0,0 +1,599 @@
1
+ import { useCallback, useMemo, useRef, useState, useEffect } from 'react';
2
+ import type { ImageRow } from '@repoimage/shared';
3
+ import { API_ORIGIN, fetchScanEntries, fetchScanFolder, FILE_PROTOCOL_ERROR } from './lib/api';
4
+ import {
5
+ applyFilters,
6
+ applySort,
7
+ barFillClass,
8
+ formatFileSize,
9
+ scoreClass,
10
+ splitPath,
11
+ type SortKey
12
+ } from './lib/format';
13
+ import {
14
+ buildEntriesFromPicked,
15
+ collectFromDirectoryHandle,
16
+ collectFromFileInputListAsync,
17
+ pickerRootLabelFromPaths,
18
+ tryDeriveAbsoluteRootFromPicked,
19
+ yieldToPaint,
20
+ type PickedFile
21
+ } from './lib/collect';
22
+ import { FsBrowser } from './components/FsBrowser';
23
+ import { Settings } from './components/Settings';
24
+
25
+ function joinPosix(root: string, rel: string): string {
26
+ const r = root.replace(/\\/g, '/').replace(/\/+$/, '');
27
+ const p = rel.replace(/\\/g, '/').replace(/^\/+/, '');
28
+ return `${r}/${p}`;
29
+ }
30
+
31
+ function pathToFileUrl(absPath: string): string {
32
+ const p = absPath.replace(/\\/g, '/');
33
+ if (/^[a-zA-Z]:/.test(p)) {
34
+ return `file:///${encodeURI(p)}`;
35
+ }
36
+ return `file://${encodeURI(p.startsWith('/') ? p : `/${p}`)}`;
37
+ }
38
+
39
+ export default function App() {
40
+ const [folderInput, setFolderInput] = useState('');
41
+ const [pickedFiles, setPickedFiles] = useState<PickedFile[] | null>(null);
42
+ const [pickedLabel, setPickedLabel] = useState('');
43
+ const [rawImages, setRawImages] = useState<ImageRow[]>([]);
44
+ const [sortKey, setSortKey] = useState<SortKey>('score-desc');
45
+ const [filterText, setFilterText] = useState('');
46
+ const [minScore, setMinScore] = useState('');
47
+ const [maxScore, setMaxScore] = useState('');
48
+ const [statusMsg, setStatusMsg] = useState('');
49
+ const [scanning, setScanning] = useState(false);
50
+ const [sourceLabel, setSourceLabel] = useState('idle');
51
+ const [fsOpen, setFsOpen] = useState(false);
52
+ const [flowPopMsg, setFlowPopMsg] = useState('');
53
+ const [flowPopOpen, setFlowPopOpen] = useState(false);
54
+ const [excludeCommonFolders, setExcludeCommonFolders] = useState(false);
55
+
56
+ const pickerRef = useRef<HTMLInputElement>(null);
57
+ const blobUrlsRef = useRef<Map<string, string>>(new Map());
58
+ const webkitPendingRef = useRef(false);
59
+
60
+ useEffect(() => {
61
+ const saved = localStorage.getItem('excludeCommonFolders');
62
+ if (saved === 'true') {
63
+ setExcludeCommonFolders(true);
64
+ }
65
+ const savedFolder = localStorage.getItem('folderInput');
66
+ if (savedFolder) {
67
+ setFolderInput(savedFolder);
68
+ }
69
+ const savedImages = localStorage.getItem('cachedScanResults');
70
+ if (savedImages) {
71
+ try {
72
+ const parsed = JSON.parse(savedImages) as ImageRow[];
73
+ setRawImages(parsed);
74
+ } catch {
75
+ // ignore invalid cache
76
+ }
77
+ }
78
+ }, []);
79
+
80
+ useEffect(() => {
81
+ localStorage.setItem('excludeCommonFolders', String(excludeCommonFolders));
82
+ }, [excludeCommonFolders]);
83
+
84
+ useEffect(() => {
85
+ localStorage.setItem('folderInput', folderInput);
86
+ }, [folderInput]);
87
+
88
+ useEffect(() => {
89
+ if (rawImages.length > 0) {
90
+ localStorage.setItem('cachedScanResults', JSON.stringify(rawImages));
91
+ }
92
+ }, [rawImages]);
93
+
94
+ const revokeBlobUrls = useCallback(() => {
95
+ for (const url of blobUrlsRef.current.values()) {
96
+ URL.revokeObjectURL(url);
97
+ }
98
+ blobUrlsRef.current.clear();
99
+ }, []);
100
+
101
+ const scanRootAbsolute = useCallback((): string | null => {
102
+ if (pickedFiles !== null) return null;
103
+ const v = folderInput.trim();
104
+ if (!v || /\s·\s/.test(v)) return null;
105
+ return v;
106
+ }, [folderInput, pickedFiles]);
107
+
108
+ const pickedBlobUrl = useCallback(
109
+ (relPath: string): string | null => {
110
+ if (!pickedFiles) return null;
111
+ const map = blobUrlsRef.current;
112
+ if (map.has(relPath)) return map.get(relPath)!;
113
+ const row = pickedFiles.find(item => item.path === relPath);
114
+ if (!row) return null;
115
+ const url = URL.createObjectURL(row.file);
116
+ map.set(relPath, url);
117
+ return url;
118
+ },
119
+ [pickedFiles]
120
+ );
121
+
122
+ const imageOpenHref = useCallback(
123
+ (img: ImageRow): string | null => {
124
+ const fromPick = pickedBlobUrl(img.path);
125
+ if (fromPick) return fromPick;
126
+
127
+ const abs = scanRootAbsolute();
128
+ if (!abs) return null;
129
+
130
+ const full = joinPosix(abs, img.path);
131
+ if (location.protocol === 'file:') {
132
+ return pathToFileUrl(full);
133
+ }
134
+ if (API_ORIGIN === null) return null;
135
+ return `/api/file?path=${encodeURIComponent(full)}`;
136
+ },
137
+ [pickedBlobUrl, scanRootAbsolute]
138
+ );
139
+
140
+ const displayImages = useMemo(() => {
141
+ const filtered = applyFilters(rawImages, filterText, minScore, maxScore);
142
+ return applySort(filtered, sortKey);
143
+ }, [rawImages, filterText, minScore, maxScore, sortKey]);
144
+
145
+ const summary = useMemo(() => {
146
+ const count = displayImages.length;
147
+ const totalSize = displayImages.reduce((s, img) => s + (img.size || 0), 0);
148
+ const scored = displayImages.filter(img => img.compressionRatio != null);
149
+ const avgScore = scored.length
150
+ ? (
151
+ scored.reduce((s, img) => s + parseFloat(img.compressionRatio!), 0) / scored.length
152
+ ).toFixed(1)
153
+ : '—';
154
+ return { count, totalSize, avgScore };
155
+ }, [displayImages]);
156
+
157
+ const syncFolderInputAfterPick = useCallback((list: PickedFile[], label: string) => {
158
+ setPickedLabel(label || '');
159
+ const derived = tryDeriveAbsoluteRootFromPicked(list);
160
+ if (derived) {
161
+ setFolderInput(derived);
162
+ setPickedFiles(null);
163
+ } else {
164
+ setPickedFiles(list);
165
+ if (list.length) {
166
+ setFolderInput(label ? `${label} · ${list.length}` : String(list.length));
167
+ } else {
168
+ setFolderInput(label || '');
169
+ }
170
+ }
171
+ }, []);
172
+
173
+ const runScan = useCallback(async () => {
174
+ const pathTrim = folderInput.trim();
175
+ const usePicker = pickedFiles !== null;
176
+
177
+ if (!usePicker && !pathTrim) {
178
+ setStatusMsg('Browse or enter a path.');
179
+ return;
180
+ }
181
+
182
+ setScanning(true);
183
+ setStatusMsg(usePicker ? 'Reading…' : 'Scanning…');
184
+ revokeBlobUrls();
185
+ await yieldToPaint();
186
+
187
+ try {
188
+ if (usePicker) {
189
+ if (pickedFiles.length === 0) {
190
+ setRawImages([]);
191
+ setSourceLabel('0 images');
192
+ setStatusMsg('');
193
+ return;
194
+ }
195
+ const entries = await buildEntriesFromPicked(pickedFiles, (done, total) => {
196
+ setStatusMsg(`Reading ${done}/${total}`);
197
+ });
198
+ setStatusMsg('Sending…');
199
+ const data = await fetchScanEntries(entries);
200
+ const issueHint = data.issues?.length ? ` (${data.issues.length})` : '';
201
+ setSourceLabel(`pick${issueHint}`);
202
+ setRawImages(data.images || []);
203
+ } else {
204
+ const data = await fetchScanFolder(pathTrim, { excludeCommonFolders });
205
+ const issueHint = data.issues?.length ? ` (${data.issues.length})` : '';
206
+ setSourceLabel(`disk${issueHint}`);
207
+ setRawImages(data.images || []);
208
+ }
209
+ setStatusMsg('');
210
+ } catch (err) {
211
+ setRawImages([]);
212
+ setStatusMsg(err instanceof Error ? err.message : String(err));
213
+ } finally {
214
+ setScanning(false);
215
+ }
216
+ }, [folderInput, pickedFiles, revokeBlobUrls, excludeCommonFolders]);
217
+
218
+ const runNativeFolderPicker = useCallback(async () => {
219
+ if (typeof window.showDirectoryPicker === 'function') {
220
+ setScanning(true);
221
+ setFlowPopOpen(true);
222
+ setFlowPopMsg('Use the system window to pick a folder, then confirm.');
223
+ setStatusMsg('Choosing folder…');
224
+ await yieldToPaint();
225
+
226
+ let handle: FileSystemDirectoryHandle;
227
+ try {
228
+ handle = await window.showDirectoryPicker({ mode: 'read' });
229
+ } catch (e) {
230
+ setFlowPopOpen(false);
231
+ setScanning(false);
232
+ setStatusMsg('');
233
+ if (e instanceof Error && e.name === 'AbortError') return;
234
+ setStatusMsg(e instanceof Error ? e.message : String(e));
235
+ return;
236
+ }
237
+
238
+ setFlowPopOpen(false);
239
+ setStatusMsg('Walking folder tree…');
240
+ await yieldToPaint();
241
+ try {
242
+ const list = await collectFromDirectoryHandle(handle, '', (visited, imgs) => {
243
+ setStatusMsg(`Walking tree… ${visited} entries · ${imgs} images`);
244
+ });
245
+ syncFolderInputAfterPick(list, handle.name || '');
246
+ setStatusMsg('');
247
+ setScanning(false);
248
+ await runScan();
249
+ } catch (e) {
250
+ setStatusMsg(e instanceof Error ? e.message : String(e));
251
+ setScanning(false);
252
+ }
253
+ } else {
254
+ webkitPendingRef.current = true;
255
+ setScanning(true);
256
+ setFlowPopOpen(true);
257
+ setFlowPopMsg('In the system window, choose the project folder, then confirm with Upload or Open.');
258
+ setStatusMsg('Choosing folder…');
259
+ await yieldToPaint();
260
+ pickerRef.current?.click();
261
+ }
262
+ }, [runScan, syncFolderInputAfterPick]);
263
+
264
+ const onPickerChange = useCallback(
265
+ async (fl: FileList | null) => {
266
+ webkitPendingRef.current = false;
267
+ if (!fl || !fl.length) {
268
+ setFlowPopOpen(false);
269
+ setScanning(false);
270
+ setStatusMsg('');
271
+ return;
272
+ }
273
+
274
+ setFlowPopMsg('Preparing file list from the folder you chose…');
275
+ setFlowPopOpen(true);
276
+ setScanning(true);
277
+ setStatusMsg('Building file list…');
278
+ await yieldToPaint();
279
+ try {
280
+ const list = await collectFromFileInputListAsync(fl, (processed, total, imgs) => {
281
+ setFlowPopMsg(`Listing files… ${processed}/${total} · ${imgs} images`);
282
+ setStatusMsg(`Reading file list… ${processed}/${total} · ${imgs} images`);
283
+ });
284
+ syncFolderInputAfterPick(list, pickerRootLabelFromPaths(list));
285
+ setStatusMsg('');
286
+ setFlowPopOpen(false);
287
+ setScanning(false);
288
+ await runScan();
289
+ } catch (e) {
290
+ setStatusMsg(e instanceof Error ? e.message : String(e));
291
+ setFlowPopOpen(false);
292
+ setScanning(false);
293
+ }
294
+ },
295
+ [runScan, syncFolderInputAfterPick]
296
+ );
297
+
298
+ const handleClearData = useCallback(() => {
299
+ localStorage.clear();
300
+ setFolderInput('');
301
+ setPickedFiles(null);
302
+ setPickedLabel('');
303
+ setRawImages([]);
304
+ setSortKey('score-desc');
305
+ setFilterText('');
306
+ setMinScore('');
307
+ setMaxScore('');
308
+ setStatusMsg('');
309
+ setExcludeCommonFolders(false);
310
+ }, []);
311
+
312
+ const showResults = displayImages.length > 0;
313
+ const showEmptyStatus =
314
+ !showResults && (statusMsg || rawImages.length === 0 || displayImages.length === 0);
315
+
316
+ return (
317
+ <>
318
+ {API_ORIGIN === null ? (
319
+ <div className="file-protocol-banner" role="alert">
320
+ {FILE_PROTOCOL_ERROR}
321
+ </div>
322
+ ) : null}
323
+
324
+ <header className="top-bar">
325
+ <div className="top-bar__left">
326
+ <h1 className="top-bar__title">RepoImage</h1>
327
+ <p className="top-bar__subtitle">Repository image compression analyser</p>
328
+ </div>
329
+ <Settings
330
+ excludeCommonFolders={excludeCommonFolders}
331
+ onExcludeChange={setExcludeCommonFolders}
332
+ onClearData={handleClearData}
333
+ />
334
+ </header>
335
+
336
+ <main className="main">
337
+ <section className="controls">
338
+ <div className="controls__group">
339
+ <label className="controls__label" htmlFor="folder-input">
340
+ Project root
341
+ </label>
342
+ <div className="controls__input-row controls__input-row--wrap">
343
+ <input
344
+ type="text"
345
+ id="folder-input"
346
+ className="controls__text-input"
347
+ placeholder="/path/to/project"
348
+ autoComplete="off"
349
+ spellCheck={false}
350
+ value={folderInput}
351
+ disabled={scanning}
352
+ onChange={e => {
353
+ setFolderInput(e.target.value);
354
+ setPickedFiles(null);
355
+ setPickedLabel('');
356
+ }}
357
+ onKeyDown={e => {
358
+ if (e.key === 'Enter') void runScan();
359
+ }}
360
+ />
361
+ <input
362
+ ref={pickerRef}
363
+ type="file"
364
+ className="visually-hidden"
365
+ // @ts-expect-error webkitdirectory
366
+ webkitdirectory=""
367
+ directory=""
368
+ multiple
369
+ tabIndex={-1}
370
+ aria-hidden
371
+ onChange={e => void onPickerChange(e.target.files)}
372
+ onCancel={() => {
373
+ if (!webkitPendingRef.current) return;
374
+ webkitPendingRef.current = false;
375
+ setFlowPopOpen(false);
376
+ setScanning(false);
377
+ setStatusMsg('');
378
+ }}
379
+ />
380
+ <button
381
+ type="button"
382
+ className={`btn btn--primary${scanning ? ' btn--working' : ''}`}
383
+ disabled={scanning}
384
+ onClick={() => setFsOpen(true)}
385
+ >
386
+ Browse…
387
+ </button>
388
+ </div>
389
+ <div
390
+ className={`scan-progress${scanning ? ' scan-progress--active' : ''}`}
391
+ aria-hidden={scanning ? 'false' : 'true'}
392
+ >
393
+ <div className="scan-progress__bar" />
394
+ </div>
395
+ </div>
396
+
397
+ <div className="controls__group controls__group--row">
398
+ <div className="controls__field">
399
+ <label className="controls__label" htmlFor="sort-select">
400
+ Sort by
401
+ </label>
402
+ <select
403
+ id="sort-select"
404
+ className="controls__select"
405
+ value={sortKey}
406
+ onChange={e => setSortKey(e.target.value as SortKey)}
407
+ >
408
+ <option value="path-asc">Path A–Z</option>
409
+ <option value="path-desc">Path Z–A</option>
410
+ <option value="size-desc">Size (largest first)</option>
411
+ <option value="size-asc">Size (smallest first)</option>
412
+ <option value="score-desc">Compression score (worst first)</option>
413
+ <option value="score-asc">Compression score (best first)</option>
414
+ <option value="dimensions-desc">Dimensions (largest first)</option>
415
+ <option value="dimensions-asc">Dimensions (smallest first)</option>
416
+ </select>
417
+ </div>
418
+ <div className="controls__field">
419
+ <label className="controls__label" htmlFor="filter-input">
420
+ Filter path
421
+ </label>
422
+ <input
423
+ type="text"
424
+ id="filter-input"
425
+ className="controls__text-input controls__text-input--short"
426
+ placeholder="e.g. .png or assets/"
427
+ value={filterText}
428
+ onChange={e => setFilterText(e.target.value)}
429
+ />
430
+ </div>
431
+ <div className="controls__field">
432
+ <label className="controls__label" htmlFor="min-score">
433
+ Min score
434
+ </label>
435
+ <input
436
+ type="number"
437
+ id="min-score"
438
+ className="controls__text-input controls__text-input--tiny"
439
+ min={0}
440
+ max={10}
441
+ step={0.1}
442
+ placeholder="0"
443
+ value={minScore}
444
+ onChange={e => setMinScore(e.target.value)}
445
+ />
446
+ </div>
447
+ <div className="controls__field">
448
+ <label className="controls__label" htmlFor="max-score">
449
+ Max score
450
+ </label>
451
+ <input
452
+ type="number"
453
+ id="max-score"
454
+ className="controls__text-input controls__text-input--tiny"
455
+ min={0}
456
+ max={10}
457
+ step={0.1}
458
+ placeholder="10"
459
+ value={maxScore}
460
+ onChange={e => setMaxScore(e.target.value)}
461
+ />
462
+ </div>
463
+ </div>
464
+ </section>
465
+
466
+ {rawImages.length > 0 ? (
467
+ <section className="summary">
468
+ <span className="summary__stat">
469
+ <strong>{summary.count}</strong> image{summary.count !== 1 ? 's' : ''} shown
470
+ </span>
471
+ <span className="summary__stat">
472
+ Total size: <strong>{formatFileSize(summary.totalSize)}</strong>
473
+ </span>
474
+ <span className="summary__stat">
475
+ Avg compression score: <strong>{summary.avgScore}</strong>/10
476
+ </span>
477
+ </section>
478
+ ) : null}
479
+
480
+ {showEmptyStatus && statusMsg ? (
481
+ <section className="status">
482
+ <p className="status__message">{statusMsg}</p>
483
+ </section>
484
+ ) : null}
485
+
486
+ {showResults ? (
487
+ <section className="results-container">
488
+ <table className="results-table">
489
+ <thead>
490
+ <tr>
491
+ <th className="results-table__th results-table__th--num">#</th>
492
+ <th className="results-table__th results-table__th--path">Path</th>
493
+ <th className="results-table__th results-table__th--size">File size</th>
494
+ <th className="results-table__th results-table__th--dims">Dimensions</th>
495
+ <th className="results-table__th results-table__th--uncomp">Uncompressed</th>
496
+ <th className="results-table__th results-table__th--score">Score</th>
497
+ <th className="results-table__th results-table__th--bar">Compression</th>
498
+ </tr>
499
+ </thead>
500
+ <tbody>
501
+ {displayImages.map((img, i) => {
502
+ const { dir, file } = splitPath(img.path);
503
+ const dims =
504
+ img.width && img.height ? `${img.width} × ${img.height}` : '—';
505
+ const scoreVal = img.compressionRatio ?? 'N/A';
506
+ const scorePercent =
507
+ img.compressionRatio != null
508
+ ? (parseFloat(img.compressionRatio) / 10) * 100
509
+ : 0;
510
+ const href = imageOpenHref(img);
511
+ return (
512
+ <tr key={img.path}>
513
+ <td className="cell--num">{i + 1}</td>
514
+ <td className="cell--path">
515
+ <span className="cell--path__dir">{dir}</span>
516
+ {href ? (
517
+ <a
518
+ className="cell--path__file cell--path__link"
519
+ href={href}
520
+ target="_blank"
521
+ rel="noopener noreferrer"
522
+ >
523
+ {file}
524
+ </a>
525
+ ) : (
526
+ <span className="cell--path__file">{file}</span>
527
+ )}
528
+ </td>
529
+ <td className="cell--size">{formatFileSize(img.size)}</td>
530
+ <td className="cell--dims">{dims}</td>
531
+ <td className="cell--uncomp">{formatFileSize(img.uncompressedSize)}</td>
532
+ <td className={`cell--score ${scoreClass(img.compressionRatio)}`}>
533
+ {scoreVal}
534
+ </td>
535
+ <td className="cell--bar">
536
+ <div className="bar">
537
+ <div
538
+ className={`bar__fill ${barFillClass(img.compressionRatio)}`}
539
+ style={{ width: `${scorePercent}%` }}
540
+ />
541
+ </div>
542
+ </td>
543
+ </tr>
544
+ );
545
+ })}
546
+ </tbody>
547
+ </table>
548
+ </section>
549
+ ) : showEmptyStatus && !statusMsg ? (
550
+ <section className="status">
551
+ <p className="status__message">
552
+ {rawImages.length === 0 ? 'No images.' : 'No images match the current filters.'}
553
+ </p>
554
+ </section>
555
+ ) : null}
556
+ </main>
557
+
558
+ <footer className="footer">
559
+ <p>
560
+ RepoImage GUI — run <code>npm run gui</code> or <code>npm run dev</code> ·{' '}
561
+ <span>{sourceLabel}</span>
562
+ </p>
563
+ </footer>
564
+
565
+ <FsBrowser
566
+ open={fsOpen}
567
+ onClose={() => setFsOpen(false)}
568
+ onUseFolder={path => {
569
+ setFolderInput(path);
570
+ setPickedFiles(null);
571
+ setPickedLabel('');
572
+ setFsOpen(false);
573
+ void runScan();
574
+ }}
575
+ onSystemPick={() => {
576
+ setFsOpen(false);
577
+ void runNativeFolderPicker();
578
+ }}
579
+ fileProtocolError={API_ORIGIN === null ? FILE_PROTOCOL_ERROR : null}
580
+ />
581
+
582
+ <div
583
+ className={`folder-flow-pop${flowPopOpen ? '' : ' hidden'}`}
584
+ aria-hidden={!flowPopOpen}
585
+ >
586
+ <div className="folder-flow-pop__backdrop" aria-hidden />
587
+ <div className="folder-flow-pop__panel" role="dialog" aria-modal={false}>
588
+ <h2 className="folder-flow-pop__title">Folder</h2>
589
+ <p className="folder-flow-pop__msg" aria-live="polite">
590
+ {flowPopMsg}
591
+ </p>
592
+ <div className="folder-flow-pop__track">
593
+ <div className="folder-flow-pop__bar" />
594
+ </div>
595
+ </div>
596
+ </div>
597
+ </>
598
+ );
599
+ }