From c13ee5003fb6117cf03a41adc10afc7f84b5d825 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Wed, 13 May 2026 15:32:52 +0200 Subject: [PATCH] =?UTF-8?q?refactor(device-sync):=20G.77=20=E2=80=94=20ext?= =?UTF-8?q?ract=20Header=20+=20PreSyncModal=20+=20MigrationModal=20(cluste?= =?UTF-8?q?r)=20(#644)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three-cut cluster pulling the chrome out of DeviceSync.tsx. 739 → 501 LOC (−238). DeviceSyncHeader — title row, fixed-scheme info block (with the "Reorganize existing files…" migrate button), and the drive picker row (manual folder picker, refresh, CustomSelect over detected drives or no-drives fallback, drive metadata line). DeviceSyncPreSyncModal — the modal that opens before sync execution: loading spinner while calculate_sync_payload runs, then the delta-stats grid (add count + bytes, delete count + bytes, net change, available space) with the space-warning when add exceeds available + del, plus the cancel / proceed footer. DeviceSyncMigrationModal — the migrate-existing-files modal with its five-phase state machine (loading / nothing / preview / executing / done): preview lists rename count + unchanged count + collision warning + old-template note; done shows ok / failed counts + a collapsible error list capped at 50 entries. DeviceSync drops the inline JSX + the now-unused HardDriveUpload / FolderOpen / Usb / RefreshCw / Loader2 (partially) icon imports that only the header used. Pure code move otherwise. --- .../deviceSync/DeviceSyncHeader.tsx | 123 ++++++++ .../deviceSync/DeviceSyncMigrationModal.tsx | 136 ++++++++ .../deviceSync/DeviceSyncPreSyncModal.tsx | 76 +++++ src/pages/DeviceSync.tsx | 296 ++---------------- 4 files changed, 369 insertions(+), 262 deletions(-) create mode 100644 src/components/deviceSync/DeviceSyncHeader.tsx create mode 100644 src/components/deviceSync/DeviceSyncMigrationModal.tsx create mode 100644 src/components/deviceSync/DeviceSyncPreSyncModal.tsx diff --git a/src/components/deviceSync/DeviceSyncHeader.tsx b/src/components/deviceSync/DeviceSyncHeader.tsx new file mode 100644 index 00000000..3767ee9f --- /dev/null +++ b/src/components/deviceSync/DeviceSyncHeader.tsx @@ -0,0 +1,123 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { + AlertCircle, FolderOpen, HardDriveUpload, RefreshCw, Usb, +} from 'lucide-react'; +import CustomSelect from '../CustomSelect'; +import type { RemovableDrive } from '../../utils/deviceSyncHelpers'; +import { formatBytes } from '../../utils/deviceSyncHelpers'; +import type { DeviceSyncSource } from '../../store/deviceSyncStore'; + +interface Props { + targetDir: string | null; + setTargetDir: (dir: string) => void; + sources: DeviceSyncSource[]; + drives: RemovableDrive[]; + drivesLoading: boolean; + activeDrive: RemovableDrive | null; + refreshDrives: () => Promise; + scanDevice: () => Promise; + handleChooseFolder: () => Promise; + startMigrationPreview: () => Promise; +} + +export default function DeviceSyncHeader({ + targetDir, setTargetDir, sources, drives, drivesLoading, activeDrive, + refreshDrives, scanDevice, handleChooseFolder, startMigrationPreview, +}: Props) { + const { t } = useTranslation(); + + return ( +
+
+ +

{t('deviceSync.title')}

+
+ +
+ + {/* ── Left: Fixed schema info ── */} +
+ {t('deviceSync.schemaLabel', { defaultValue: 'Naming scheme' })} + + {'{AlbumArtist}/{Album}/{TrackNum} - {Title}.{ext}'} + + + {t('deviceSync.schemaHint', { + defaultValue: 'Fixed scheme for reliable cross-OS sync. Playlists are written as .m3u8 that reference the album tracks — no duplicates on the device.', + })} + + {targetDir && sources.length > 0 && ( + + )} +
+ + {/* ── Right: Drive config ── */} +
+ {t('deviceSync.targetDevice')} +
+
+ {/* Row 1: Controls */} +
+ {/* Fallback manual folder picker & Refresh */} + + + + {/* Dropdown element */} + {drives.length > 0 ? ( + <> + + { + setTargetDir(v); + if (v) { + setTimeout(() => scanDevice(), 100); + } + }} + options={[ + { value: '', label: t('deviceSync.selectDrive') }, + ...drives.map(d => ({ value: d.mount_point, label: d.name || d.mount_point })) + ]} + /> + + ) : ( + + + {t('deviceSync.noDrivesDetected')} + + )} +
+ + {/* Row 2: Metadata */} + {activeDrive && ( +
+ {formatBytes(activeDrive.available_space)} {t('deviceSync.free')} / {formatBytes(activeDrive.total_space)} • {activeDrive.file_system} +
+ )} +
+
+
+
+
+ ); +} diff --git a/src/components/deviceSync/DeviceSyncMigrationModal.tsx b/src/components/deviceSync/DeviceSyncMigrationModal.tsx new file mode 100644 index 00000000..be0767af --- /dev/null +++ b/src/components/deviceSync/DeviceSyncMigrationModal.tsx @@ -0,0 +1,136 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { AlertCircle, CheckCircle2, Loader2 } from 'lucide-react'; +import type { + MigrationPair, MigrationPhase, MigrationResult, +} from '../../utils/runDeviceSyncMigration'; + +interface Props { + migrationPhase: MigrationPhase; + migrationOldTemplate: string; + migrationPairs: MigrationPair[]; + migrationCollisions: MigrationPair[]; + migrationUnchanged: number; + migrationResult: MigrationResult | null; + executeMigration: () => Promise; + closeMigration: () => void; +} + +export default function DeviceSyncMigrationModal({ + migrationPhase, migrationOldTemplate, migrationPairs, migrationCollisions, + migrationUnchanged, migrationResult, executeMigration, closeMigration, +}: Props) { + const { t } = useTranslation(); + + if (migrationPhase === 'closed') return null; + + return ( +
+
e.stopPropagation()}> +

{t('deviceSync.migrateTitle', { defaultValue: 'Reorganize existing files' })}

+
+ {migrationPhase === 'loading' && ( +
+ + {t('deviceSync.migrateLoading', { defaultValue: 'Analyzing existing files…' })} +
+ )} + {migrationPhase === 'nothing' && ( +
+ {migrationOldTemplate ? ( + t('deviceSync.migrateNothingToDo', { defaultValue: 'All existing files already match the new scheme — nothing to do.' }) + ) : ( + t('deviceSync.migrateNoTemplate', { defaultValue: 'No legacy filename template found on the device. Migration only applies when the stick was synced with a Psysonic version that supported custom templates.' }) + )} +
+ )} + {migrationPhase === 'preview' && ( + <> +
+
+ {migrationPairs.length}{' '} + {t('deviceSync.migrateFilesToRename', { defaultValue: 'files will be renamed' })} +
+ {migrationUnchanged > 0 && ( +
+ {t('deviceSync.migrateUnchanged', { + defaultValue: '{{n}} files are already at the correct path', + n: migrationUnchanged, + })} +
+ )} + {migrationCollisions.length > 0 && ( +
+ + {t('deviceSync.migrateCollisions', { + defaultValue: '{{n}} files cannot be renamed automatically (multiple tracks map to the same target). They will be left untouched — the next sync re-downloads them into the correct location.', + n: migrationCollisions.length, + })} +
+ )} +
+
+ {t('deviceSync.migratePreviewNote', { + defaultValue: 'Old template: {{tpl}}', + tpl: migrationOldTemplate, + })} +
+ + )} + {migrationPhase === 'executing' && ( +
+ + {t('deviceSync.migrateExecuting', { defaultValue: 'Renaming files…' })} +
+ )} + {migrationPhase === 'done' && migrationResult && ( +
+
+ + {t('deviceSync.migrateSuccess', { + defaultValue: '{{n}} files renamed successfully', + n: migrationResult.ok, + })} +
+ {migrationResult.failed > 0 && ( +
+ + {t('deviceSync.migrateFailed', { + defaultValue: '{{n}} renames failed', + n: migrationResult.failed, + })} +
+ )} + {migrationResult.errors.length > 0 && ( +
+ {t('deviceSync.migrateShowErrors', { defaultValue: 'Show errors' })} +
    + {migrationResult.errors.slice(0, 50).map((err, i) => ( +
  • {err}
  • + ))} + {migrationResult.errors.length > 50 && ( +
  • … {migrationResult.errors.length - 50} more
  • + )} +
+
+ )} +
+ )} +
+
+ {migrationPhase === 'preview' && ( + <> + + + + )} + {(migrationPhase === 'done' || migrationPhase === 'nothing') && ( + + )} +
+
+
+ ); +} diff --git a/src/components/deviceSync/DeviceSyncPreSyncModal.tsx b/src/components/deviceSync/DeviceSyncPreSyncModal.tsx new file mode 100644 index 00000000..ae317c38 --- /dev/null +++ b/src/components/deviceSync/DeviceSyncPreSyncModal.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { AlertCircle, Loader2 } from 'lucide-react'; +import type { SyncDelta } from '../../utils/runDeviceSyncExecution'; + +interface Props { + preSyncOpen: boolean; + preSyncLoading: boolean; + syncDelta: SyncDelta; + onCancel: () => void; + onProceed: () => void; +} + +export default function DeviceSyncPreSyncModal({ + preSyncOpen, preSyncLoading, syncDelta, onCancel, onProceed, +}: Props) { + const { t } = useTranslation(); + + if (!preSyncOpen) return null; + + return ( +
+
+

{t('deviceSync.syncSummary')}

+ + {preSyncLoading ? ( +
+ +

{t('deviceSync.calculating')}

+
+ ) : ( +
+
+ {t('deviceSync.filesToAdd')} + +{syncDelta.addCount} ({(syncDelta.addBytes / 1_048_576).toFixed(1)} MB) +
+
+ {t('deviceSync.filesToDelete')} + -{syncDelta.delCount} ({(syncDelta.delBytes / 1_048_576).toFixed(1)} MB) +
+
+
+ {t('deviceSync.netChange')} + {((syncDelta.addBytes - syncDelta.delBytes) / 1_048_576).toFixed(1)} MB +
+
syncDelta.availableBytes + syncDelta.delBytes ? 'var(--danger)' : 'inherit', marginTop: '10px' }}> + {t('deviceSync.availableSpace')} + {(syncDelta.availableBytes / 1_048_576).toFixed(1)} MB +
+ {syncDelta.addBytes > syncDelta.availableBytes + syncDelta.delBytes && ( +
+ + {t('deviceSync.spaceWarning')} +
+ )} +
+ )} + + {!preSyncLoading && ( +
+ + +
+ )} +
+
+ ); +} diff --git a/src/pages/DeviceSync.tsx b/src/pages/DeviceSync.tsx index 860fc39c..2b5aa12d 100644 --- a/src/pages/DeviceSync.tsx +++ b/src/pages/DeviceSync.tsx @@ -3,9 +3,9 @@ import type { SubsonicSong } from '../api/subsonicTypes'; import React, { useState, useCallback, useMemo } from 'react'; import { invoke } from '@tauri-apps/api/core'; import { - HardDriveUpload, FolderOpen, Loader2, + HardDriveUpload, Loader2, ListMusic, Disc3, Users, CheckCircle2, AlertCircle, Clock, - ChevronRight, ChevronDown, Trash2, Undo2, Search, Usb, RefreshCw, Shuffle, Zap, X, + ChevronRight, ChevronDown, Trash2, Undo2, Search, Shuffle, Zap, X, } from 'lucide-react'; import CustomSelect from '../components/CustomSelect'; import { useTranslation } from 'react-i18next'; @@ -35,6 +35,9 @@ import { type SyncDelta, } from '../utils/runDeviceSyncExecution'; import { runDeviceSyncChooseFolder } from '../utils/runDeviceSyncChooseFolder'; +import DeviceSyncHeader from '../components/deviceSync/DeviceSyncHeader'; +import DeviceSyncPreSyncModal from '../components/deviceSync/DeviceSyncPreSyncModal'; +import DeviceSyncMigrationModal from '../components/deviceSync/DeviceSyncMigrationModal'; // ─── component ─────────────────────────────────────────────────────────────── @@ -204,100 +207,18 @@ export default function DeviceSync() { return (
- {/* ── Header ── */} -
-
- -

{t('deviceSync.title')}

-
- -
- - {/* ── Left: Fixed schema info ── */} -
- {t('deviceSync.schemaLabel', { defaultValue: 'Naming scheme' })} - - {'{AlbumArtist}/{Album}/{TrackNum} - {Title}.{ext}'} - - - {t('deviceSync.schemaHint', { - defaultValue: 'Fixed scheme for reliable cross-OS sync. Playlists are written as .m3u8 that reference the album tracks — no duplicates on the device.', - })} - - {targetDir && sources.length > 0 && ( - - )} -
- - {/* ── Right: Drive config ── */} -
- {t('deviceSync.targetDevice')} -
-
- {/* Row 1: Controls */} -
- {/* Fallback manual folder picker & Refresh */} - - - - {/* Dropdown element */} - {drives.length > 0 ? ( - <> - - { - setTargetDir(v); - if (v) { - setTimeout(() => scanDevice(), 100); - } - }} - options={[ - { value: '', label: t('deviceSync.selectDrive') }, - ...drives.map(d => ({ value: d.mount_point, label: d.name || d.mount_point })) - ]} - /> - - ) : ( - - - {t('deviceSync.noDrivesDetected')} - - )} -
- - {/* Row 2: Metadata */} - {activeDrive && ( -
- {formatBytes(activeDrive.available_space)} {t('deviceSync.free')} / {formatBytes(activeDrive.total_space)} • {activeDrive.file_system} -
- )} -
-
-
-
-
- - + {/* ── Main ── */}
@@ -567,173 +488,24 @@ export default function DeviceSync() {
- {/* Pre-Sync Summary Modal */} - {preSyncOpen && ( -
-
-

{t('deviceSync.syncSummary')}

+ setPreSyncOpen(false)} + onProceed={handleSyncExecution} + /> - {preSyncLoading ? ( -
- -

{t('deviceSync.calculating')}

-
- ) : ( -
-
- {t('deviceSync.filesToAdd')} - +{syncDelta.addCount} ({(syncDelta.addBytes / 1_048_576).toFixed(1)} MB) -
-
- {t('deviceSync.filesToDelete')} - -{syncDelta.delCount} ({(syncDelta.delBytes / 1_048_576).toFixed(1)} MB) -
-
-
- {t('deviceSync.netChange')} - {((syncDelta.addBytes - syncDelta.delBytes) / 1_048_576).toFixed(1)} MB -
-
syncDelta.availableBytes + syncDelta.delBytes ? 'var(--danger)' : 'inherit', marginTop: '10px' }}> - {t('deviceSync.availableSpace')} - {(syncDelta.availableBytes / 1_048_576).toFixed(1)} MB -
- {syncDelta.addBytes > syncDelta.availableBytes + syncDelta.delBytes && ( -
- - {t('deviceSync.spaceWarning')} -
- )} -
- )} - - {!preSyncLoading && ( -
- - -
- )} -
-
- )} - - {/* ── Migration modal (rename existing files into the fixed scheme) ── */} - {migrationPhase !== 'closed' && ( -
-
e.stopPropagation()}> -

{t('deviceSync.migrateTitle', { defaultValue: 'Reorganize existing files' })}

-
- {migrationPhase === 'loading' && ( -
- - {t('deviceSync.migrateLoading', { defaultValue: 'Analyzing existing files…' })} -
- )} - {migrationPhase === 'nothing' && ( -
- {migrationOldTemplate ? ( - t('deviceSync.migrateNothingToDo', { defaultValue: 'All existing files already match the new scheme — nothing to do.' }) - ) : ( - t('deviceSync.migrateNoTemplate', { defaultValue: 'No legacy filename template found on the device. Migration only applies when the stick was synced with a Psysonic version that supported custom templates.' }) - )} -
- )} - {migrationPhase === 'preview' && ( - <> -
-
- {migrationPairs.length}{' '} - {t('deviceSync.migrateFilesToRename', { defaultValue: 'files will be renamed' })} -
- {migrationUnchanged > 0 && ( -
- {t('deviceSync.migrateUnchanged', { - defaultValue: '{{n}} files are already at the correct path', - n: migrationUnchanged, - })} -
- )} - {migrationCollisions.length > 0 && ( -
- - {t('deviceSync.migrateCollisions', { - defaultValue: '{{n}} files cannot be renamed automatically (multiple tracks map to the same target). They will be left untouched — the next sync re-downloads them into the correct location.', - n: migrationCollisions.length, - })} -
- )} -
-
- {t('deviceSync.migratePreviewNote', { - defaultValue: 'Old template: {{tpl}}', - tpl: migrationOldTemplate, - })} -
- - )} - {migrationPhase === 'executing' && ( -
- - {t('deviceSync.migrateExecuting', { defaultValue: 'Renaming files…' })} -
- )} - {migrationPhase === 'done' && migrationResult && ( -
-
- - {t('deviceSync.migrateSuccess', { - defaultValue: '{{n}} files renamed successfully', - n: migrationResult.ok, - })} -
- {migrationResult.failed > 0 && ( -
- - {t('deviceSync.migrateFailed', { - defaultValue: '{{n}} renames failed', - n: migrationResult.failed, - })} -
- )} - {migrationResult.errors.length > 0 && ( -
- {t('deviceSync.migrateShowErrors', { defaultValue: 'Show errors' })} -
    - {migrationResult.errors.slice(0, 50).map((err, i) => ( -
  • {err}
  • - ))} - {migrationResult.errors.length > 50 && ( -
  • … {migrationResult.errors.length - 50} more
  • - )} -
-
- )} -
- )} -
-
- {migrationPhase === 'preview' && ( - <> - - - - )} - {(migrationPhase === 'done' || migrationPhase === 'nothing') && ( - - )} -
-
-
- )} +
); }