mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
refactor(device-sync): G.78 — extract BrowserPanel + DevicePanel (cluster) (#645)
Two-cut cluster pulling the main layout columns out of
DeviceSync.tsx. 511 → 233 LOC (−278). DeviceSync is now mostly
glue: state hooks, hook calls, action wrappers, and a flat tree of
five layout components.
DeviceSyncBrowserPanel — left column. Owns the tabs row
(playlists / albums / artists with icons), the search input with
the "Live search" badge on the albums tab, and the result list:
loading spinner, "Random albums" section label, playlist /
album / artist rows with their BrowserRow leaf component, and the
expand-an-artist tree (loading state, chevron, child album rows
with indent). filteredPlaylists / filteredArtists memos move into
the panel since only the row mapping consumes them.
DeviceSyncDevicePanel — right column. Owns the header (title +
scanning spinner + sync action button with three label variants
+ "Delete from device" button), the status badges row
(synced / pending / deletion), the source list with checkbox /
type / status icon / per-row action (mark-for-deletion /
remove-source / undo-deletion), and the bottom progress strip
(running / cancelled / done) with their dismiss / cancel
buttons. invoke('cancel_device_sync') stays in the panel since
it's a panel-local action.
DeviceSync drops the now-unused invoke / BrowserRow / useMemo
imports (filteredPlaylists/Artists moved into the panel). Pure
code move otherwise.
This commit is contained in:
committed by
GitHub
parent
c13ee5003f
commit
84c682aeb8
@@ -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<string>;
|
||||||
|
artistAlbumsMap: Map<string, SubsonicAlbum[]>;
|
||||||
|
loadingArtistIds: Set<string>;
|
||||||
|
toggleArtistExpand: (artistId: string) => Promise<void>;
|
||||||
|
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: <ListMusic size={14} />, label: t('deviceSync.tabPlaylists') },
|
||||||
|
{ key: 'albums', icon: <Disc3 size={14} />, label: t('deviceSync.tabAlbums') },
|
||||||
|
{ key: 'artists', icon: <Users size={14} />, 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 (
|
||||||
|
<div className="device-sync-browser">
|
||||||
|
<div className="device-sync-tabs">
|
||||||
|
{tabs.map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
className={`device-sync-tab${activeTab === tab.key ? ' active' : ''}`}
|
||||||
|
onClick={() => setActiveTab(tab.key)}
|
||||||
|
>
|
||||||
|
{tab.icon}{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="device-sync-search-wrap">
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
placeholder={t('deviceSync.searchPlaceholder')}
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
{activeTab === 'albums' && (
|
||||||
|
<span className="device-sync-live-badge">
|
||||||
|
<Zap size={10} />{t('deviceSync.liveSearch')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="device-sync-list">
|
||||||
|
{(loadingBrowser || albumSearchLoading) && (
|
||||||
|
<div className="device-sync-loading"><Loader2 size={16} className="spin" /></div>
|
||||||
|
)}
|
||||||
|
{activeTab === 'albums' && !search.trim() && !loadingBrowser && randomAlbums.length > 0 && (
|
||||||
|
<div className="device-sync-section-label">
|
||||||
|
<Shuffle size={11} />{t('deviceSync.randomAlbumsLabel')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{activeTab === 'playlists' && filteredPlaylists.map(pl => (
|
||||||
|
<BrowserRow key={pl.id} name={pl.name} meta={`${pl.songCount} tracks`}
|
||||||
|
selected={sources.some(s => 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 => (
|
||||||
|
<BrowserRow key={al.id} name={al.name} meta={al.artist}
|
||||||
|
selected={sources.some(s => 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 => (
|
||||||
|
<React.Fragment key={ar.id}>
|
||||||
|
<div className="device-sync-artist-row">
|
||||||
|
<button
|
||||||
|
className="device-sync-expand-btn"
|
||||||
|
onClick={() => toggleArtistExpand(ar.id)}
|
||||||
|
>
|
||||||
|
{loadingArtistIds.has(ar.id)
|
||||||
|
? <Loader2 size={13} className="spin" />
|
||||||
|
: expandedArtistIds.has(ar.id)
|
||||||
|
? <ChevronDown size={13} />
|
||||||
|
: <ChevronRight size={13} />}
|
||||||
|
</button>
|
||||||
|
<span className="device-sync-row-name">{ar.name}</span>
|
||||||
|
{ar.albumCount != null &&
|
||||||
|
<span className="device-sync-row-meta">{ar.albumCount} Albums</span>}
|
||||||
|
</div>
|
||||||
|
{expandedArtistIds.has(ar.id) && artistAlbumsMap.has(ar.id) &&
|
||||||
|
artistAlbumsMap.get(ar.id)!.map(al => (
|
||||||
|
<BrowserRow key={al.id} name={al.name} meta={al.year?.toString()}
|
||||||
|
selected={sources.some(s => s.id === al.id) && !pendingDeletion.includes(al.id)}
|
||||||
|
indent
|
||||||
|
onToggle={() => handleToggleSource({ type: 'album', id: al.id, name: al.name, artist: al.artist || ar.name })} />
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string, SyncStatus>;
|
||||||
|
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<void>;
|
||||||
|
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 (
|
||||||
|
<div className="device-sync-device-panel">
|
||||||
|
<div className="device-sync-panel-header">
|
||||||
|
<span className="device-sync-panel-title">
|
||||||
|
{t('deviceSync.onDevice')}
|
||||||
|
{scanning && <Loader2 size={12} className="spin" style={{ marginLeft: 6 }} />}
|
||||||
|
</span>
|
||||||
|
<div className="device-sync-panel-actions">
|
||||||
|
{/* Sync button */}
|
||||||
|
<button
|
||||||
|
className="btn btn-surface"
|
||||||
|
onClick={promptSyncSummary}
|
||||||
|
disabled={actionButtonDisabled}
|
||||||
|
>
|
||||||
|
{isRunning
|
||||||
|
? <><Loader2 size={13} className="spin" /> {jobDone + jobSkip + jobFail}/{jobTotal}</>
|
||||||
|
: <>
|
||||||
|
{deletionCount > 0 && pendingCount === 0
|
||||||
|
? <Trash2 size={13} />
|
||||||
|
: <HardDriveUpload size={13} />}
|
||||||
|
{actionButtonLabel}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Mark for deletion */}
|
||||||
|
{checkedIds.length > 0 && !isRunning && (
|
||||||
|
<button
|
||||||
|
className="btn btn-danger"
|
||||||
|
onClick={handleMarkCheckedForDeletion}
|
||||||
|
>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
{t('deviceSync.deleteFromDevice', { count: checkedIds.length })}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status summary badges */}
|
||||||
|
{sources.length > 0 && driveDetected && (
|
||||||
|
<div className="device-sync-status-summary">
|
||||||
|
{syncedCount > 0 && (
|
||||||
|
<span className="device-sync-badge synced">
|
||||||
|
<CheckCircle2 size={11} /> {syncedCount} {t('deviceSync.statusSynced')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{pendingCount > 0 && (
|
||||||
|
<span className="device-sync-badge pending">
|
||||||
|
<Clock size={11} /> {pendingCount} {t('deviceSync.statusPending')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{deletionCount > 0 && (
|
||||||
|
<span className="device-sync-badge deletion">
|
||||||
|
<Trash2 size={11} /> {deletionCount} {t('deviceSync.statusDeletion')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sources.length === 0 || !driveDetected ? (
|
||||||
|
<p className="device-sync-empty">{t('deviceSync.noSourcesSelected')}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="device-sync-list-header">
|
||||||
|
<label className="device-sync-check-label">
|
||||||
|
<input type="checkbox" checked={allChecked} onChange={toggleAll} />
|
||||||
|
</label>
|
||||||
|
<span className="device-sync-list-col-name">{t('deviceSync.colName')}</span>
|
||||||
|
<span className="device-sync-list-col-type">{t('deviceSync.colType')}</span>
|
||||||
|
<span className="device-sync-list-col-status">{t('deviceSync.colStatus')}</span>
|
||||||
|
<span className="device-sync-list-col-actions" />
|
||||||
|
</div>
|
||||||
|
<div className="device-sync-device-list">
|
||||||
|
{sources.map(s => {
|
||||||
|
const status = sourceStatuses.get(s.id) ?? 'pending';
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={s.id}
|
||||||
|
className={`device-sync-device-row ${status}${checkedIds.includes(s.id) ? ' checked' : ''}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checkedIds.includes(s.id)}
|
||||||
|
onChange={() => toggleChecked(s.id)}
|
||||||
|
disabled={status === 'deletion'}
|
||||||
|
/>
|
||||||
|
<span className="device-sync-row-name">
|
||||||
|
{s.name}
|
||||||
|
{s.artist && <span className="device-sync-row-artist"> · {s.artist}</span>}
|
||||||
|
</span>
|
||||||
|
<span className="device-sync-source-type">{s.type}</span>
|
||||||
|
<span className={`device-sync-status-icon ${status}`}>
|
||||||
|
{status === 'synced' && <CheckCircle2 size={13} />}
|
||||||
|
{status === 'pending' && <Clock size={13} />}
|
||||||
|
{status === 'deletion' && <Trash2 size={13} />}
|
||||||
|
</span>
|
||||||
|
<span className="device-sync-row-actions">
|
||||||
|
{status === 'synced' && (
|
||||||
|
<button
|
||||||
|
className="device-sync-action-btn danger"
|
||||||
|
onClick={e => { e.preventDefault(); markForDeletion([s.id]); }}
|
||||||
|
data-tooltip={t('deviceSync.markForDeletion')}
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{status === 'pending' && (
|
||||||
|
<button
|
||||||
|
className="device-sync-action-btn muted"
|
||||||
|
onClick={e => { e.preventDefault(); handleToggleSource(s); }}
|
||||||
|
data-tooltip={t('deviceSync.removeSource')}
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{status === 'deletion' && (
|
||||||
|
<button
|
||||||
|
className="device-sync-action-btn undo"
|
||||||
|
onClick={e => { e.preventDefault(); unmarkDeletion(s.id); }}
|
||||||
|
data-tooltip={t('deviceSync.undoDeletion')}
|
||||||
|
>
|
||||||
|
<Undo2 size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Background sync progress (non-blocking) */}
|
||||||
|
{jobStatus === 'running' && (
|
||||||
|
<div className="device-sync-bg-progress">
|
||||||
|
<div className="device-sync-bg-progress-bar-wrap">
|
||||||
|
<div
|
||||||
|
className="device-sync-bg-progress-bar"
|
||||||
|
style={{ width: jobTotal > 0
|
||||||
|
? `${((jobDone + jobSkip + jobFail) / jobTotal) * 100}%`
|
||||||
|
: '0%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="device-sync-bg-progress-text">
|
||||||
|
<Loader2 size={12} className="spin" />
|
||||||
|
{t('deviceSync.syncInProgress', { done: jobDone + jobSkip, total: jobTotal })}
|
||||||
|
{jobFail > 0 && <span className="device-sync-stat-error"><AlertCircle size={11} /> {jobFail}</span>}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ fontSize: 12, padding: '2px 10px' }}
|
||||||
|
onClick={() => {
|
||||||
|
const jobId = useDeviceSyncJobStore.getState().jobId;
|
||||||
|
if (jobId) invoke('cancel_device_sync', { jobId });
|
||||||
|
useDeviceSyncJobStore.getState().cancel();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('deviceSync.cancelSync')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{jobStatus === 'cancelled' && (
|
||||||
|
<div className="device-sync-bg-progress done">
|
||||||
|
<span className="device-sync-bg-progress-text">
|
||||||
|
<AlertCircle size={12} style={{ color: 'var(--text-muted)' }} />
|
||||||
|
{t('deviceSync.syncCancelled', { done: jobDone, total: jobTotal })}
|
||||||
|
</span>
|
||||||
|
<button className="btn btn-ghost" onClick={() => useDeviceSyncJobStore.getState().reset()}>
|
||||||
|
{t('deviceSync.dismiss')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{jobStatus === 'done' && (
|
||||||
|
<div className="device-sync-bg-progress done">
|
||||||
|
<span className="device-sync-bg-progress-text">
|
||||||
|
<CheckCircle2 size={12} className="color-success" />
|
||||||
|
{t('deviceSync.syncResult', { done: jobDone, skipped: jobSkip, total: jobTotal })}
|
||||||
|
</span>
|
||||||
|
<button className="btn btn-ghost" onClick={() => useDeviceSyncJobStore.getState().reset()}>
|
||||||
|
{t('deviceSync.dismiss')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+47
-267
@@ -1,7 +1,6 @@
|
|||||||
import { buildDownloadUrl } from '../api/subsonicStreamUrl';
|
import { buildDownloadUrl } from '../api/subsonicStreamUrl';
|
||||||
import type { SubsonicSong } from '../api/subsonicTypes';
|
import type { SubsonicSong } from '../api/subsonicTypes';
|
||||||
import React, { useState, useCallback, useMemo } from 'react';
|
import React, { useState, useCallback, useMemo } from 'react';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
|
||||||
import {
|
import {
|
||||||
HardDriveUpload, Loader2,
|
HardDriveUpload, Loader2,
|
||||||
ListMusic, Disc3, Users, CheckCircle2, AlertCircle, Clock,
|
ListMusic, Disc3, Users, CheckCircle2, AlertCircle, Clock,
|
||||||
@@ -18,7 +17,6 @@ import {
|
|||||||
formatBytes,
|
formatBytes,
|
||||||
type SourceTab,
|
type SourceTab,
|
||||||
} from '../utils/deviceSyncHelpers';
|
} from '../utils/deviceSyncHelpers';
|
||||||
import BrowserRow from '../components/deviceSync/BrowserRow';
|
|
||||||
import { useDeviceSyncDrives } from '../hooks/useDeviceSyncDrives';
|
import { useDeviceSyncDrives } from '../hooks/useDeviceSyncDrives';
|
||||||
import { useDeviceSyncSourceStatuses } from '../hooks/useDeviceSyncSourceStatuses';
|
import { useDeviceSyncSourceStatuses } from '../hooks/useDeviceSyncSourceStatuses';
|
||||||
import { useDeviceSyncBrowser } from '../hooks/useDeviceSyncBrowser';
|
import { useDeviceSyncBrowser } from '../hooks/useDeviceSyncBrowser';
|
||||||
@@ -38,6 +36,8 @@ import { runDeviceSyncChooseFolder } from '../utils/runDeviceSyncChooseFolder';
|
|||||||
import DeviceSyncHeader from '../components/deviceSync/DeviceSyncHeader';
|
import DeviceSyncHeader from '../components/deviceSync/DeviceSyncHeader';
|
||||||
import DeviceSyncPreSyncModal from '../components/deviceSync/DeviceSyncPreSyncModal';
|
import DeviceSyncPreSyncModal from '../components/deviceSync/DeviceSyncPreSyncModal';
|
||||||
import DeviceSyncMigrationModal from '../components/deviceSync/DeviceSyncMigrationModal';
|
import DeviceSyncMigrationModal from '../components/deviceSync/DeviceSyncMigrationModal';
|
||||||
|
import DeviceSyncBrowserPanel from '../components/deviceSync/DeviceSyncBrowserPanel';
|
||||||
|
import DeviceSyncDevicePanel from '../components/deviceSync/DeviceSyncDevicePanel';
|
||||||
|
|
||||||
// ─── component ───────────────────────────────────────────────────────────────
|
// ─── component ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -130,10 +130,6 @@ export default function DeviceSync() {
|
|||||||
toggleArtistExpand,
|
toggleArtistExpand,
|
||||||
} = useDeviceSyncBrowser(activeTab, search, () => setSearch(''));
|
} = 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 ─────────────────────────────────────────────────
|
// ─── Migration handlers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
const startMigrationPreview = () => runDeviceSyncMigrationPreview({
|
const startMigrationPreview = () => runDeviceSyncMigrationPreview({
|
||||||
@@ -223,268 +219,52 @@ export default function DeviceSync() {
|
|||||||
{/* ── Main ── */}
|
{/* ── Main ── */}
|
||||||
<div className="device-sync-main">
|
<div className="device-sync-main">
|
||||||
|
|
||||||
{/* ── Browser (left) ── */}
|
<DeviceSyncBrowserPanel
|
||||||
<div className="device-sync-browser">
|
activeTab={activeTab}
|
||||||
<div className="device-sync-tabs">
|
setActiveTab={setActiveTab}
|
||||||
{tabs.map(tab => (
|
search={search}
|
||||||
<button
|
setSearch={setSearch}
|
||||||
key={tab.key}
|
playlists={playlists}
|
||||||
className={`device-sync-tab${activeTab === tab.key ? ' active' : ''}`}
|
randomAlbums={randomAlbums}
|
||||||
onClick={() => setActiveTab(tab.key)}
|
albumSearchResults={albumSearchResults}
|
||||||
>
|
albumSearchLoading={albumSearchLoading}
|
||||||
{tab.icon}{tab.label}
|
artists={artists}
|
||||||
</button>
|
loadingBrowser={loadingBrowser}
|
||||||
))}
|
expandedArtistIds={expandedArtistIds}
|
||||||
</div>
|
artistAlbumsMap={artistAlbumsMap}
|
||||||
<div className="device-sync-search-wrap">
|
loadingArtistIds={loadingArtistIds}
|
||||||
<input
|
toggleArtistExpand={toggleArtistExpand}
|
||||||
className="input"
|
sources={sources}
|
||||||
placeholder={t('deviceSync.searchPlaceholder')}
|
pendingDeletion={pendingDeletion}
|
||||||
value={search}
|
handleToggleSource={handleToggleSource}
|
||||||
onChange={e => setSearch(e.target.value)}
|
/>
|
||||||
/>
|
|
||||||
{activeTab === 'albums' && (
|
|
||||||
<span className="device-sync-live-badge">
|
|
||||||
<Zap size={10} />{t('deviceSync.liveSearch')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="device-sync-list">
|
|
||||||
{(loadingBrowser || albumSearchLoading) && (
|
|
||||||
<div className="device-sync-loading"><Loader2 size={16} className="spin" /></div>
|
|
||||||
)}
|
|
||||||
{activeTab === 'albums' && !search.trim() && !loadingBrowser && randomAlbums.length > 0 && (
|
|
||||||
<div className="device-sync-section-label">
|
|
||||||
<Shuffle size={11} />{t('deviceSync.randomAlbumsLabel')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{activeTab === 'playlists' && filteredPlaylists.map(pl => (
|
|
||||||
<BrowserRow key={pl.id} name={pl.name} meta={`${pl.songCount} tracks`}
|
|
||||||
selected={sources.some(s => 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 => (
|
|
||||||
<BrowserRow key={al.id} name={al.name} meta={al.artist}
|
|
||||||
selected={sources.some(s => 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 => (
|
|
||||||
<React.Fragment key={ar.id}>
|
|
||||||
<div className="device-sync-artist-row">
|
|
||||||
<button
|
|
||||||
className="device-sync-expand-btn"
|
|
||||||
onClick={() => toggleArtistExpand(ar.id)}
|
|
||||||
>
|
|
||||||
{loadingArtistIds.has(ar.id)
|
|
||||||
? <Loader2 size={13} className="spin" />
|
|
||||||
: expandedArtistIds.has(ar.id)
|
|
||||||
? <ChevronDown size={13} />
|
|
||||||
: <ChevronRight size={13} />}
|
|
||||||
</button>
|
|
||||||
<span className="device-sync-row-name">{ar.name}</span>
|
|
||||||
{ar.albumCount != null &&
|
|
||||||
<span className="device-sync-row-meta">{ar.albumCount} Albums</span>}
|
|
||||||
</div>
|
|
||||||
{expandedArtistIds.has(ar.id) && artistAlbumsMap.has(ar.id) &&
|
|
||||||
artistAlbumsMap.get(ar.id)!.map(al => (
|
|
||||||
<BrowserRow key={al.id} name={al.name} meta={al.year?.toString()}
|
|
||||||
selected={sources.some(s => s.id === al.id) && !pendingDeletion.includes(al.id)}
|
|
||||||
indent
|
|
||||||
onToggle={() => handleToggleSource({ type: 'album', id: al.id, name: al.name, artist: al.artist || ar.name })} />
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Device Manager (right) ── */}
|
<DeviceSyncDevicePanel
|
||||||
<div className="device-sync-device-panel">
|
sources={sources}
|
||||||
<div className="device-sync-panel-header">
|
sourceStatuses={sourceStatuses}
|
||||||
<span className="device-sync-panel-title">
|
driveDetected={driveDetected}
|
||||||
{t('deviceSync.onDevice')}
|
scanning={scanning}
|
||||||
{scanning && <Loader2 size={12} className="spin" style={{ marginLeft: 6 }} />}
|
checkedIds={checkedIds}
|
||||||
</span>
|
toggleChecked={toggleChecked}
|
||||||
<div className="device-sync-panel-actions">
|
allChecked={allChecked}
|
||||||
{/* Sync button */}
|
toggleAll={toggleAll}
|
||||||
<button
|
syncedCount={syncedCount}
|
||||||
className="btn btn-surface"
|
pendingCount={pendingCount}
|
||||||
onClick={promptSyncSummary}
|
deletionCount={deletionCount}
|
||||||
disabled={actionButtonDisabled}
|
isRunning={isRunning}
|
||||||
>
|
actionButtonLabel={actionButtonLabel}
|
||||||
{isRunning
|
actionButtonDisabled={actionButtonDisabled}
|
||||||
? <><Loader2 size={13} className="spin" /> {jobDone + jobSkip + jobFail}/{jobTotal}</>
|
promptSyncSummary={promptSyncSummary}
|
||||||
: <>
|
handleMarkCheckedForDeletion={handleMarkCheckedForDeletion}
|
||||||
{deletionCount > 0 && pendingCount === 0
|
handleToggleSource={handleToggleSource}
|
||||||
? <Trash2 size={13} />
|
markForDeletion={markForDeletion}
|
||||||
: <HardDriveUpload size={13} />}
|
unmarkDeletion={unmarkDeletion}
|
||||||
{actionButtonLabel}
|
jobStatus={jobStatus}
|
||||||
</>
|
jobDone={jobDone}
|
||||||
}
|
jobSkip={jobSkip}
|
||||||
</button>
|
jobFail={jobFail}
|
||||||
|
jobTotal={jobTotal}
|
||||||
{/* Mark for deletion */}
|
/>
|
||||||
{checkedIds.length > 0 && !isRunning && (
|
|
||||||
<button
|
|
||||||
className="btn btn-danger"
|
|
||||||
onClick={handleMarkCheckedForDeletion}
|
|
||||||
>
|
|
||||||
<Trash2 size={13} />
|
|
||||||
{t('deviceSync.deleteFromDevice', { count: checkedIds.length })}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status summary badges */}
|
|
||||||
{sources.length > 0 && driveDetected && (
|
|
||||||
<div className="device-sync-status-summary">
|
|
||||||
{syncedCount > 0 && (
|
|
||||||
<span className="device-sync-badge synced">
|
|
||||||
<CheckCircle2 size={11} /> {syncedCount} {t('deviceSync.statusSynced')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{pendingCount > 0 && (
|
|
||||||
<span className="device-sync-badge pending">
|
|
||||||
<Clock size={11} /> {pendingCount} {t('deviceSync.statusPending')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{deletionCount > 0 && (
|
|
||||||
<span className="device-sync-badge deletion">
|
|
||||||
<Trash2 size={11} /> {deletionCount} {t('deviceSync.statusDeletion')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{sources.length === 0 || !driveDetected ? (
|
|
||||||
<p className="device-sync-empty">{t('deviceSync.noSourcesSelected')}</p>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="device-sync-list-header">
|
|
||||||
<label className="device-sync-check-label">
|
|
||||||
<input type="checkbox" checked={allChecked} onChange={toggleAll} />
|
|
||||||
</label>
|
|
||||||
<span className="device-sync-list-col-name">{t('deviceSync.colName')}</span>
|
|
||||||
<span className="device-sync-list-col-type">{t('deviceSync.colType')}</span>
|
|
||||||
<span className="device-sync-list-col-status">{t('deviceSync.colStatus')}</span>
|
|
||||||
<span className="device-sync-list-col-actions" />
|
|
||||||
</div>
|
|
||||||
<div className="device-sync-device-list">
|
|
||||||
{sources.map(s => {
|
|
||||||
const status = sourceStatuses.get(s.id) ?? 'pending';
|
|
||||||
return (
|
|
||||||
<label
|
|
||||||
key={s.id}
|
|
||||||
className={`device-sync-device-row ${status}${checkedIds.includes(s.id) ? ' checked' : ''}`}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={checkedIds.includes(s.id)}
|
|
||||||
onChange={() => toggleChecked(s.id)}
|
|
||||||
disabled={status === 'deletion'}
|
|
||||||
/>
|
|
||||||
<span className="device-sync-row-name">
|
|
||||||
{s.name}
|
|
||||||
{s.artist && <span className="device-sync-row-artist"> · {s.artist}</span>}
|
|
||||||
</span>
|
|
||||||
<span className="device-sync-source-type">{s.type}</span>
|
|
||||||
<span className={`device-sync-status-icon ${status}`}>
|
|
||||||
{status === 'synced' && <CheckCircle2 size={13} />}
|
|
||||||
{status === 'pending' && <Clock size={13} />}
|
|
||||||
{status === 'deletion' && <Trash2 size={13} />}
|
|
||||||
</span>
|
|
||||||
<span className="device-sync-row-actions">
|
|
||||||
{status === 'synced' && (
|
|
||||||
<button
|
|
||||||
className="device-sync-action-btn danger"
|
|
||||||
onClick={e => { e.preventDefault(); markForDeletion([s.id]); }}
|
|
||||||
data-tooltip={t('deviceSync.markForDeletion')}
|
|
||||||
>
|
|
||||||
<Trash2 size={12} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{status === 'pending' && (
|
|
||||||
<button
|
|
||||||
className="device-sync-action-btn muted"
|
|
||||||
onClick={e => { e.preventDefault(); handleToggleSource(s); }}
|
|
||||||
data-tooltip={t('deviceSync.removeSource')}
|
|
||||||
>
|
|
||||||
<Trash2 size={12} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{status === 'deletion' && (
|
|
||||||
<button
|
|
||||||
className="device-sync-action-btn undo"
|
|
||||||
onClick={e => { e.preventDefault(); unmarkDeletion(s.id); }}
|
|
||||||
data-tooltip={t('deviceSync.undoDeletion')}
|
|
||||||
>
|
|
||||||
<Undo2 size={12} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Background sync progress (non-blocking) */}
|
|
||||||
{jobStatus === 'running' && (
|
|
||||||
<div className="device-sync-bg-progress">
|
|
||||||
<div className="device-sync-bg-progress-bar-wrap">
|
|
||||||
<div
|
|
||||||
className="device-sync-bg-progress-bar"
|
|
||||||
style={{ width: jobTotal > 0
|
|
||||||
? `${((jobDone + jobSkip + jobFail) / jobTotal) * 100}%`
|
|
||||||
: '0%' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="device-sync-bg-progress-text">
|
|
||||||
<Loader2 size={12} className="spin" />
|
|
||||||
{t('deviceSync.syncInProgress', { done: jobDone + jobSkip, total: jobTotal })}
|
|
||||||
{jobFail > 0 && <span className="device-sync-stat-error"><AlertCircle size={11} /> {jobFail}</span>}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
className="btn btn-ghost"
|
|
||||||
style={{ fontSize: 12, padding: '2px 10px' }}
|
|
||||||
onClick={() => {
|
|
||||||
const jobId = useDeviceSyncJobStore.getState().jobId;
|
|
||||||
if (jobId) invoke('cancel_device_sync', { jobId });
|
|
||||||
useDeviceSyncJobStore.getState().cancel();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('deviceSync.cancelSync')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{jobStatus === 'cancelled' && (
|
|
||||||
<div className="device-sync-bg-progress done">
|
|
||||||
<span className="device-sync-bg-progress-text">
|
|
||||||
<AlertCircle size={12} style={{ color: 'var(--text-muted)' }} />
|
|
||||||
{t('deviceSync.syncCancelled', { done: jobDone, total: jobTotal })}
|
|
||||||
</span>
|
|
||||||
<button className="btn btn-ghost" onClick={() => useDeviceSyncJobStore.getState().reset()}>
|
|
||||||
{t('deviceSync.dismiss')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{jobStatus === 'done' && (
|
|
||||||
<div className="device-sync-bg-progress done">
|
|
||||||
<span className="device-sync-bg-progress-text">
|
|
||||||
<CheckCircle2 size={12} className="color-success" />
|
|
||||||
{t('deviceSync.syncResult', { done: jobDone, skipped: jobSkip, total: jobTotal })}
|
|
||||||
</span>
|
|
||||||
<button className="btn btn-ghost" onClick={() => useDeviceSyncJobStore.getState().reset()}>
|
|
||||||
{t('deviceSync.dismiss')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user