diff --git a/src/components/deviceSync/DeviceSyncBrowserPanel.tsx b/src/components/deviceSync/DeviceSyncBrowserPanel.tsx new file mode 100644 index 00000000..38000ce5 --- /dev/null +++ b/src/components/deviceSync/DeviceSyncBrowserPanel.tsx @@ -0,0 +1,128 @@ +import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + ChevronDown, ChevronRight, Disc3, ListMusic, + Loader2, Shuffle, Users, Zap, +} from 'lucide-react'; +import type { + SubsonicAlbum, SubsonicArtist, SubsonicPlaylist, +} from '../../api/subsonicTypes'; +import type { DeviceSyncSource } from '../../store/deviceSyncStore'; +import type { SourceTab } from '../../utils/deviceSyncHelpers'; +import BrowserRow from './BrowserRow'; + +interface Props { + activeTab: SourceTab; + setActiveTab: (t: SourceTab) => void; + search: string; + setSearch: (v: string) => void; + playlists: SubsonicPlaylist[]; + randomAlbums: SubsonicAlbum[]; + albumSearchResults: SubsonicAlbum[]; + albumSearchLoading: boolean; + artists: SubsonicArtist[]; + loadingBrowser: boolean; + expandedArtistIds: Set; + artistAlbumsMap: Map; + loadingArtistIds: Set; + toggleArtistExpand: (artistId: string) => Promise; + sources: DeviceSyncSource[]; + pendingDeletion: string[]; + handleToggleSource: (source: DeviceSyncSource) => void; +} + +export default function DeviceSyncBrowserPanel({ + activeTab, setActiveTab, search, setSearch, + playlists, randomAlbums, albumSearchResults, albumSearchLoading, + artists, loadingBrowser, + expandedArtistIds, artistAlbumsMap, loadingArtistIds, toggleArtistExpand, + sources, pendingDeletion, handleToggleSource, +}: Props) { + const { t } = useTranslation(); + + const tabs: { key: SourceTab; icon: React.ReactNode; label: string }[] = [ + { key: 'playlists', icon: , label: t('deviceSync.tabPlaylists') }, + { key: 'albums', icon: , label: t('deviceSync.tabAlbums') }, + { key: 'artists', icon: , label: t('deviceSync.tabArtists') }, + ]; + + const q = search.toLowerCase(); + const filteredPlaylists = useMemo(() => playlists.filter(p => p.name.toLowerCase().includes(q)), [playlists, q]); + const filteredArtists = useMemo(() => artists.filter(a => a.name.toLowerCase().includes(q)), [artists, q]); + + return ( +
+
+ {tabs.map(tab => ( + + ))} +
+
+ setSearch(e.target.value)} + /> + {activeTab === 'albums' && ( + + {t('deviceSync.liveSearch')} + + )} +
+
+ {(loadingBrowser || albumSearchLoading) && ( +
+ )} + {activeTab === 'albums' && !search.trim() && !loadingBrowser && randomAlbums.length > 0 && ( +
+ {t('deviceSync.randomAlbumsLabel')} +
+ )} + {activeTab === 'playlists' && filteredPlaylists.map(pl => ( + s.id === pl.id) && !pendingDeletion.includes(pl.id)} + onToggle={() => handleToggleSource({ type: 'playlist', id: pl.id, name: pl.name })} /> + ))} + {activeTab === 'albums' && (search.trim() ? albumSearchResults : randomAlbums).map(al => ( + s.id === al.id) && !pendingDeletion.includes(al.id)} + onToggle={() => handleToggleSource({ type: 'album', id: al.id, name: al.name, artist: al.artist })} /> + ))} + {activeTab === 'artists' && filteredArtists.map(ar => ( + +
+ + {ar.name} + {ar.albumCount != null && + {ar.albumCount} Albums} +
+ {expandedArtistIds.has(ar.id) && artistAlbumsMap.has(ar.id) && + artistAlbumsMap.get(ar.id)!.map(al => ( + s.id === al.id) && !pendingDeletion.includes(al.id)} + indent + onToggle={() => handleToggleSource({ type: 'album', id: al.id, name: al.name, artist: al.artist || ar.name })} /> + )) + } +
+ ))} +
+
+ ); +} diff --git a/src/components/deviceSync/DeviceSyncDevicePanel.tsx b/src/components/deviceSync/DeviceSyncDevicePanel.tsx new file mode 100644 index 00000000..bb9d4476 --- /dev/null +++ b/src/components/deviceSync/DeviceSyncDevicePanel.tsx @@ -0,0 +1,237 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { invoke } from '@tauri-apps/api/core'; +import { + AlertCircle, CheckCircle2, Clock, HardDriveUpload, Loader2, + Trash2, Undo2, +} from 'lucide-react'; +import { useDeviceSyncJobStore } from '../../store/deviceSyncJobStore'; +import type { DeviceSyncSource } from '../../store/deviceSyncStore'; +import type { SyncStatus } from '../../utils/deviceSyncHelpers'; + +interface Props { + sources: DeviceSyncSource[]; + sourceStatuses: Map; + driveDetected: boolean; + scanning: boolean; + checkedIds: string[]; + toggleChecked: (id: string) => void; + allChecked: boolean; + toggleAll: () => void; + syncedCount: number; + pendingCount: number; + deletionCount: number; + isRunning: boolean; + actionButtonLabel: string; + actionButtonDisabled: boolean; + promptSyncSummary: () => Promise; + handleMarkCheckedForDeletion: () => void; + handleToggleSource: (source: DeviceSyncSource) => void; + markForDeletion: (ids: string[]) => void; + unmarkDeletion: (id: string) => void; + jobStatus: string; + jobDone: number; + jobSkip: number; + jobFail: number; + jobTotal: number; +} + +export default function DeviceSyncDevicePanel({ + sources, sourceStatuses, driveDetected, scanning, + checkedIds, toggleChecked, allChecked, toggleAll, + syncedCount, pendingCount, deletionCount, + isRunning, actionButtonLabel, actionButtonDisabled, + promptSyncSummary, handleMarkCheckedForDeletion, handleToggleSource, + markForDeletion, unmarkDeletion, + jobStatus, jobDone, jobSkip, jobFail, jobTotal, +}: Props) { + const { t } = useTranslation(); + + return ( +
+
+ + {t('deviceSync.onDevice')} + {scanning && } + +
+ {/* Sync button */} + + + {/* Mark for deletion */} + {checkedIds.length > 0 && !isRunning && ( + + )} +
+
+ + {/* Status summary badges */} + {sources.length > 0 && driveDetected && ( +
+ {syncedCount > 0 && ( + + {syncedCount} {t('deviceSync.statusSynced')} + + )} + {pendingCount > 0 && ( + + {pendingCount} {t('deviceSync.statusPending')} + + )} + {deletionCount > 0 && ( + + {deletionCount} {t('deviceSync.statusDeletion')} + + )} +
+ )} + + {sources.length === 0 || !driveDetected ? ( +

{t('deviceSync.noSourcesSelected')}

+ ) : ( + <> +
+ + {t('deviceSync.colName')} + {t('deviceSync.colType')} + {t('deviceSync.colStatus')} + +
+
+ {sources.map(s => { + const status = sourceStatuses.get(s.id) ?? 'pending'; + return ( + + ); + })} +
+ + )} + + {/* Background sync progress (non-blocking) */} + {jobStatus === 'running' && ( +
+
+
0 + ? `${((jobDone + jobSkip + jobFail) / jobTotal) * 100}%` + : '0%' }} + /> +
+ + + {t('deviceSync.syncInProgress', { done: jobDone + jobSkip, total: jobTotal })} + {jobFail > 0 && {jobFail}} + + +
+ )} + + {jobStatus === 'cancelled' && ( +
+ + + {t('deviceSync.syncCancelled', { done: jobDone, total: jobTotal })} + + +
+ )} + + {jobStatus === 'done' && ( +
+ + + {t('deviceSync.syncResult', { done: jobDone, skipped: jobSkip, total: jobTotal })} + + +
+ )} +
+ ); +} diff --git a/src/pages/DeviceSync.tsx b/src/pages/DeviceSync.tsx index 2b5aa12d..07efca09 100644 --- a/src/pages/DeviceSync.tsx +++ b/src/pages/DeviceSync.tsx @@ -1,7 +1,6 @@ import { buildDownloadUrl } from '../api/subsonicStreamUrl'; import type { SubsonicSong } from '../api/subsonicTypes'; import React, { useState, useCallback, useMemo } from 'react'; -import { invoke } from '@tauri-apps/api/core'; import { HardDriveUpload, Loader2, ListMusic, Disc3, Users, CheckCircle2, AlertCircle, Clock, @@ -18,7 +17,6 @@ import { formatBytes, type SourceTab, } from '../utils/deviceSyncHelpers'; -import BrowserRow from '../components/deviceSync/BrowserRow'; import { useDeviceSyncDrives } from '../hooks/useDeviceSyncDrives'; import { useDeviceSyncSourceStatuses } from '../hooks/useDeviceSyncSourceStatuses'; import { useDeviceSyncBrowser } from '../hooks/useDeviceSyncBrowser'; @@ -38,6 +36,8 @@ import { runDeviceSyncChooseFolder } from '../utils/runDeviceSyncChooseFolder'; import DeviceSyncHeader from '../components/deviceSync/DeviceSyncHeader'; import DeviceSyncPreSyncModal from '../components/deviceSync/DeviceSyncPreSyncModal'; import DeviceSyncMigrationModal from '../components/deviceSync/DeviceSyncMigrationModal'; +import DeviceSyncBrowserPanel from '../components/deviceSync/DeviceSyncBrowserPanel'; +import DeviceSyncDevicePanel from '../components/deviceSync/DeviceSyncDevicePanel'; // ─── component ─────────────────────────────────────────────────────────────── @@ -130,10 +130,6 @@ export default function DeviceSync() { toggleArtistExpand, } = useDeviceSyncBrowser(activeTab, search, () => setSearch('')); - const q = search.toLowerCase(); - const filteredPlaylists = useMemo(() => playlists.filter(p => p.name.toLowerCase().includes(q)), [playlists, q]); - const filteredArtists = useMemo(() => artists.filter(a => a.name.toLowerCase().includes(q)), [artists, q]); - // ─── Migration handlers ───────────────────────────────────────────────── const startMigrationPreview = () => runDeviceSyncMigrationPreview({ @@ -223,268 +219,52 @@ export default function DeviceSync() { {/* ── Main ── */}
- {/* ── Browser (left) ── */} -
-
- {tabs.map(tab => ( - - ))} -
-
- setSearch(e.target.value)} - /> - {activeTab === 'albums' && ( - - {t('deviceSync.liveSearch')} - - )} -
-
- {(loadingBrowser || albumSearchLoading) && ( -
- )} - {activeTab === 'albums' && !search.trim() && !loadingBrowser && randomAlbums.length > 0 && ( -
- {t('deviceSync.randomAlbumsLabel')} -
- )} - {activeTab === 'playlists' && filteredPlaylists.map(pl => ( - s.id === pl.id) && !pendingDeletion.includes(pl.id)} - onToggle={() => handleToggleSource({ type: 'playlist', id: pl.id, name: pl.name })} /> - ))} - {activeTab === 'albums' && (search.trim() ? albumSearchResults : randomAlbums).map(al => ( - s.id === al.id) && !pendingDeletion.includes(al.id)} - onToggle={() => handleToggleSource({ type: 'album', id: al.id, name: al.name, artist: al.artist })} /> - ))} - {activeTab === 'artists' && filteredArtists.map(ar => ( - -
- - {ar.name} - {ar.albumCount != null && - {ar.albumCount} Albums} -
- {expandedArtistIds.has(ar.id) && artistAlbumsMap.has(ar.id) && - artistAlbumsMap.get(ar.id)!.map(al => ( - s.id === al.id) && !pendingDeletion.includes(al.id)} - indent - onToggle={() => handleToggleSource({ type: 'album', id: al.id, name: al.name, artist: al.artist || ar.name })} /> - )) - } -
- ))} -
-
+ - {/* ── Device Manager (right) ── */} -
-
- - {t('deviceSync.onDevice')} - {scanning && } - -
- {/* Sync button */} - - - {/* Mark for deletion */} - {checkedIds.length > 0 && !isRunning && ( - - )} -
-
- - {/* Status summary badges */} - {sources.length > 0 && driveDetected && ( -
- {syncedCount > 0 && ( - - {syncedCount} {t('deviceSync.statusSynced')} - - )} - {pendingCount > 0 && ( - - {pendingCount} {t('deviceSync.statusPending')} - - )} - {deletionCount > 0 && ( - - {deletionCount} {t('deviceSync.statusDeletion')} - - )} -
- )} - - {sources.length === 0 || !driveDetected ? ( -

{t('deviceSync.noSourcesSelected')}

- ) : ( - <> -
- - {t('deviceSync.colName')} - {t('deviceSync.colType')} - {t('deviceSync.colStatus')} - -
-
- {sources.map(s => { - const status = sourceStatuses.get(s.id) ?? 'pending'; - return ( - - ); - })} -
- - )} - - {/* Background sync progress (non-blocking) */} - {jobStatus === 'running' && ( -
-
-
0 - ? `${((jobDone + jobSkip + jobFail) / jobTotal) * 100}%` - : '0%' }} - /> -
- - - {t('deviceSync.syncInProgress', { done: jobDone + jobSkip, total: jobTotal })} - {jobFail > 0 && {jobFail}} - - -
- )} - - {jobStatus === 'cancelled' && ( -
- - - {t('deviceSync.syncCancelled', { done: jobDone, total: jobTotal })} - - -
- )} - - {jobStatus === 'done' && ( -
- - - {t('deviceSync.syncResult', { done: jobDone, skipped: jobSkip, total: jobTotal })} - - -
- )} - -
+