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.
- package/.claude/settings.local.json +8 -0
- package/AGENTS.md +28 -0
- package/PROJECT-AGENTS.md +55 -0
- package/README.md +153 -0
- package/TODO.md +132 -0
- package/client/index.html +12 -0
- package/client/package.json +23 -0
- package/client/src/App.tsx +599 -0
- package/client/src/components/FsBrowser.tsx +210 -0
- package/client/src/components/Settings.tsx +81 -0
- package/client/src/index.css +797 -0
- package/client/src/lib/api.ts +69 -0
- package/client/src/lib/collect.ts +204 -0
- package/client/src/lib/format.ts +96 -0
- package/client/src/lib/session.ts +58 -0
- package/client/src/main.tsx +10 -0
- package/client/src/vite-env.d.ts +1 -0
- package/client/tsconfig.json +18 -0
- package/client/vite.config.ts +27 -0
- package/docs/README.md +28 -0
- package/docs/api/overview.md +65 -0
- package/docs/api/scan.md +188 -0
- package/docs/architecture.md +155 -0
- package/docs/design/invariants.md +19 -0
- package/docs/design/role-system.md +50 -0
- package/docs/development/README.md +94 -0
- package/docs/features/README.md +21 -0
- package/docs/features/compression-score.md +75 -0
- package/docs/features/exclusions.md +63 -0
- package/docs/features/session.md +64 -0
- package/package.json +37 -0
- package/server/dist/cli.d.ts +3 -0
- package/server/dist/cli.d.ts.map +1 -0
- package/server/dist/cli.js +54 -0
- package/server/dist/cli.js.map +1 -0
- package/server/dist/fs-list.d.ts +3 -0
- package/server/dist/fs-list.d.ts.map +1 -0
- package/server/dist/fs-list.js +73 -0
- package/server/dist/fs-list.js.map +1 -0
- package/server/dist/paths.d.ts +3 -0
- package/server/dist/paths.d.ts.map +1 -0
- package/server/dist/paths.js +12 -0
- package/server/dist/paths.js.map +1 -0
- package/server/dist/scan.d.ts +16 -0
- package/server/dist/scan.d.ts.map +1 -0
- package/server/dist/scan.js +158 -0
- package/server/dist/scan.js.map +1 -0
- package/server/dist/server.d.ts +6 -0
- package/server/dist/server.d.ts.map +1 -0
- package/server/dist/server.js +313 -0
- package/server/dist/server.js.map +1 -0
- package/server/package.json +22 -0
- package/server/src/cli.ts +63 -0
- package/server/src/fs-list.ts +70 -0
- package/server/src/paths.ts +6 -0
- package/server/src/scan.ts +203 -0
- package/server/src/server.ts +356 -0
- package/server/tsconfig.json +9 -0
- package/shared/package.json +10 -0
- package/shared/src/constants.ts +37 -0
- package/shared/src/index.ts +4 -0
- package/shared/src/role-guess.ts +103 -0
- package/shared/src/schema.ts +18 -0
- package/shared/src/types.ts +36 -0
- package/shared/tsconfig.json +9 -0
- package/test/cli.test.js +56 -0
- package/test/fs-list.test.js +39 -0
- package/test/role-guess.test.js +50 -0
- package/test/scan.test.js +150 -0
- package/test/server.test.js +308 -0
- 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
|
+
}
|