diff --git a/README.md b/README.md
index b24f5f2..e8ab4b1 100644
--- a/README.md
+++ b/README.md
@@ -24,11 +24,19 @@ The app runs entirely on your local machine. Open it via a start script, a brows
| Connect | Setup |
|---------|-------|
-|  |  |
+|  |  |
-| Transfer | Summary |
-|----------|---------|
-|  |  |
+| Setup (date range active) | Transfer |
+|--------------------------|----------|
+|  |  |
+
+| Summary | Summary – files copied detail |
+|---------|-------------------------------|
+|  |  |
+
+| Summary – files skipped detail | Summary – errors detail |
+|--------------------------------|-------------------------|
+|  |  |
---
diff --git a/docs/screenshots/setup.png b/docs/screenshots/setup 1.png
similarity index 100%
rename from docs/screenshots/setup.png
rename to docs/screenshots/setup 1.png
diff --git a/docs/screenshots/setup 2.png b/docs/screenshots/setup 2.png
new file mode 100644
index 0000000..1579029
Binary files /dev/null and b/docs/screenshots/setup 2.png differ
diff --git a/docs/screenshots/summary 1.png b/docs/screenshots/summary 1.png
new file mode 100644
index 0000000..91b7571
Binary files /dev/null and b/docs/screenshots/summary 1.png differ
diff --git a/docs/screenshots/summary 2.png b/docs/screenshots/summary 2.png
new file mode 100644
index 0000000..1f11289
Binary files /dev/null and b/docs/screenshots/summary 2.png differ
diff --git a/docs/screenshots/summary 3.png b/docs/screenshots/summary 3.png
new file mode 100644
index 0000000..ebec7d3
Binary files /dev/null and b/docs/screenshots/summary 3.png differ
diff --git a/docs/screenshots/summary 4.png b/docs/screenshots/summary 4.png
new file mode 100644
index 0000000..319fc0d
Binary files /dev/null and b/docs/screenshots/summary 4.png differ
diff --git a/docs/screenshots/summary.png b/docs/screenshots/summary.png
deleted file mode 100644
index 8bed96b..0000000
Binary files a/docs/screenshots/summary.png and /dev/null differ
diff --git a/docs/screenshots/transfer.png b/docs/screenshots/transfer.png
index 9e74200..54725e9 100644
Binary files a/docs/screenshots/transfer.png and b/docs/screenshots/transfer.png differ
diff --git a/public/app.js b/public/app.js
index daf9511..2c85b1e 100644
--- a/public/app.js
+++ b/public/app.js
@@ -13,6 +13,7 @@ const state = {
browserWarningShown: false,
homeDir: null, // local user home directory (fetched from API)
ws: null, // active WebSocket during transfer
+ lastSummary: null, // summary payload for detail panel
};
const PHONE_DEFAULT_PATH = {
@@ -139,6 +140,11 @@ document.addEventListener('DOMContentLoaded', async () => {
// Summary OK button
document.getElementById('btn-ok').addEventListener('click', resetToStart);
+ // Summary detail tiles
+ document.querySelectorAll('[data-detail]').forEach(tile => {
+ tile.addEventListener('click', () => toggleDetailPanel(tile.dataset.detail));
+ });
+
showScreen('connect');
startDevicePolling();
});
@@ -592,19 +598,86 @@ async function resolveConflict(action) {
}
// ─── Summary ───────────────────────────────────────────────────────────────────
-function showSummary({ copied, skipped, errors }) {
+function showSummary(msg) {
+ const { copied, skipped, errors } = msg;
document.getElementById('sum-copied').textContent = copied;
document.getElementById('sum-skipped').textContent = skipped;
document.getElementById('sum-errors').textContent = errors;
+ state.lastSummary = msg;
+
stopDeviceWatch();
if (state.ws) { state.ws.close(); state.ws = null; }
showScreen('summary');
}
+function toggleDetailPanel(type) {
+ const panel = document.getElementById('summary-detail-panel');
+ const content = document.getElementById('summary-detail-content');
+ const tiles = document.querySelectorAll('[data-detail]');
+
+ const alreadyOpen = !panel.hidden && panel.dataset.activeType === type;
+
+ tiles.forEach(tile => tile.classList.remove('active'));
+ if (alreadyOpen) {
+ panel.hidden = true;
+ panel.dataset.activeType = '';
+ return;
+ }
+
+ panel.dataset.activeType = type;
+ panel.hidden = false;
+ document.querySelector(`[data-detail="${type}"]`).classList.add('active');
+ content.innerHTML = renderDetail(type, state.lastSummary);
+}
+
+function renderDetail(type, s) {
+ if (type === 'copied') {
+ const rows = [
+ ['images', t('summary.images')],
+ ['videos', t('summary.videos')],
+ ['music', t('summary.music')],
+ ['files', t('summary.files')],
+ ].filter(([k]) => s.copiedByType[k] > 0)
+ .map(([k, label]) =>
+ `
${esc(label)}${s.copiedByType[k]}
`
+ ).join('');
+ return rows || `${esc(t('summary.noDetails'))}
`;
+ }
+ if (type === 'skipped') {
+ if (!s.skippedFiles.length)
+ return `${esc(t('summary.noDetails'))}
`;
+ return s.skippedFiles
+ .map(f => `${esc(f)}
`)
+ .join('');
+ }
+ if (type === 'errors') {
+ if (!s.errorDetails.length)
+ return `${esc(t('summary.noDetails'))}
`;
+ return s.errorDetails
+ .map(e => `${esc(e.file)}${esc(e.reason)}
`)
+ .join('');
+ }
+ return '';
+}
+
+function esc(str) {
+ return String(str)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+}
+
// ─── Reset ─────────────────────────────────────────────────────────────────────
function resetToStart() {
+ const detailPanel = document.getElementById('summary-detail-panel');
+ detailPanel.hidden = true;
+ detailPanel.dataset.activeType = '';
+ document.querySelectorAll('[data-detail]').forEach(tile => tile.classList.remove('active'));
+ state.lastSummary = null;
+
state.sourcePath = null;
state.destPath = null;
state.fileType = 'allTypes';
diff --git a/public/i18n/da.json b/public/i18n/da.json
index a2bcc1d..48bbb5e 100644
--- a/public/i18n/da.json
+++ b/public/i18n/da.json
@@ -89,7 +89,12 @@
"copied": "Filer kopieret",
"skipped": "Filer sprunget over",
"errors": "Fejl",
- "ok": "Tilbage til start"
+ "ok": "Tilbage til start",
+ "images": "Fotos",
+ "videos": "Videoer",
+ "music": "Musik",
+ "files": "Filer",
+ "noDetails": "Ingen detaljer"
},
"browser": {
"titlePhone": "Vælg kildemappe på telefonen",
diff --git a/public/i18n/en.json b/public/i18n/en.json
index 7a800f8..d2498dd 100644
--- a/public/i18n/en.json
+++ b/public/i18n/en.json
@@ -89,7 +89,12 @@
"copied": "Files copied",
"skipped": "Files skipped",
"errors": "Errors",
- "ok": "Back to Start"
+ "ok": "Back to Start",
+ "images": "Photos",
+ "videos": "Videos",
+ "music": "Music",
+ "files": "Files",
+ "noDetails": "No details"
},
"browser": {
"titlePhone": "Select source folder on phone",
diff --git a/public/i18n/es.json b/public/i18n/es.json
index 008f6bd..5fc2045 100644
--- a/public/i18n/es.json
+++ b/public/i18n/es.json
@@ -89,7 +89,12 @@
"copied": "Archivos copiados",
"skipped": "Archivos omitidos",
"errors": "Errores",
- "ok": "Volver al inicio"
+ "ok": "Volver al inicio",
+ "images": "Fotos",
+ "videos": "Videos",
+ "music": "Música",
+ "files": "Archivos",
+ "noDetails": "Sin detalles"
},
"browser": {
"titlePhone": "Seleccionar carpeta de origen en el teléfono",
diff --git a/public/index.html b/public/index.html
index 812013e..1070873 100644
--- a/public/index.html
+++ b/public/index.html
@@ -194,19 +194,22 @@ Scanning phone for files..
Transfer Complete
-
+
0
Files copied
-
+
0
Files skipped
-
+
diff --git a/public/style.css b/public/style.css
index b224e1b..07c1f7a 100644
--- a/public/style.css
+++ b/public/style.css
@@ -467,7 +467,12 @@ main {
font-size: 0.78rem;
color: var(--muted);
text-align: center;
+ cursor: pointer;
+ outline: 2px solid transparent;
+ transition: outline-color 0.15s, opacity 0.15s;
}
+.summary-item:hover { opacity: 0.8; }
+.summary-item.active { outline-color: var(--accent); }
.summary-num {
font-size: 2rem;
@@ -478,6 +483,34 @@ main {
.summary-item.success .summary-num { color: var(--success); }
.summary-item.danger .summary-num { color: var(--error); }
+.summary-detail-panel {
+ margin-top: 12px;
+ padding: 10px 12px;
+ border-radius: var(--radius-sm);
+ background: var(--bg);
+ max-height: 200px;
+ overflow-y: auto;
+ font-size: 0.83rem;
+}
+
+.detail-row {
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 5px 0;
+ border-bottom: 1px solid var(--border);
+ word-break: break-all;
+}
+.detail-row:last-child { border-bottom: none; }
+.detail-row .detail-reason {
+ color: var(--muted);
+ word-break: break-word;
+}
+.detail-row--error {
+ flex-direction: column;
+ gap: 2px;
+}
+
/* ─── Modal overlay ──────────────────────────────────────────────────────── */
.modal-overlay {
position: fixed;
diff --git a/src/services/TransferService.ts b/src/services/TransferService.ts
index 58e47d8..c99dd94 100644
--- a/src/services/TransferService.ts
+++ b/src/services/TransferService.ts
@@ -34,9 +34,12 @@ export interface ScanEvent {
}
export interface SummaryEvent {
- copied: number;
- skipped: number;
- errors: number;
+ copied: number;
+ skipped: number;
+ errors: number;
+ copiedByType: { images: number; videos: number; music: number; files: number };
+ skippedFiles: string[];
+ errorDetails: { file: string; reason: string }[];
}
const IMAGE_EXTENSIONS = new Set([
@@ -125,7 +128,12 @@ export class TransferService extends EventEmitter {
// Tell the frontend how many files were found before copying begins
this.emit('scan', { total: filtered.length });
- const summary: SummaryEvent = { copied: 0, skipped: 0, errors: 0 };
+ const summary: SummaryEvent = {
+ copied: 0, skipped: 0, errors: 0,
+ copiedByType: { images: 0, videos: 0, music: 0, files: 0 },
+ skippedFiles: [],
+ errorDetails: [],
+ };
// 'skip-all' or 'overwrite-all' applied to all remaining conflicts
let globalAction: 'skip-all' | 'overwrite-all' | null = null;
@@ -189,6 +197,7 @@ export class TransferService extends EventEmitter {
if (action === 'skip' || action === 'skip-all') {
summary.skipped++;
+ summary.skippedFiles.push(filename);
await this.files.deleteFile(tempPath);
continue;
}
@@ -196,6 +205,11 @@ export class TransferService extends EventEmitter {
await this.moveToDest(tempPath, destPath);
summary.copied++;
+ const category =
+ FILE_EXTENSIONS.has(ext) ? 'files' :
+ MUSIC_EXTENSIONS.has(ext) ? 'music' :
+ VIDEO_EXTENSIONS.has(ext) ? 'videos' : 'images';
+ summary.copiedByType[category]++;
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
const deviceGone =
@@ -211,6 +225,7 @@ export class TransferService extends EventEmitter {
}
summary.errors++;
+ summary.errorDetails.push({ file: filename, reason: msg });
await this.files.deleteFile(tempPath);
}
}