mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +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 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 ── */}
|
||||
<div className="device-sync-main">
|
||||
|
||||
{/* ── Browser (left) ── */}
|
||||
<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>
|
||||
<DeviceSyncBrowserPanel
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
search={search}
|
||||
setSearch={setSearch}
|
||||
playlists={playlists}
|
||||
randomAlbums={randomAlbums}
|
||||
albumSearchResults={albumSearchResults}
|
||||
albumSearchLoading={albumSearchLoading}
|
||||
artists={artists}
|
||||
loadingBrowser={loadingBrowser}
|
||||
expandedArtistIds={expandedArtistIds}
|
||||
artistAlbumsMap={artistAlbumsMap}
|
||||
loadingArtistIds={loadingArtistIds}
|
||||
toggleArtistExpand={toggleArtistExpand}
|
||||
sources={sources}
|
||||
pendingDeletion={pendingDeletion}
|
||||
handleToggleSource={handleToggleSource}
|
||||
/>
|
||||
|
||||
{/* ── Device Manager (right) ── */}
|
||||
<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>
|
||||
<DeviceSyncDevicePanel
|
||||
sources={sources}
|
||||
sourceStatuses={sourceStatuses}
|
||||
driveDetected={driveDetected}
|
||||
scanning={scanning}
|
||||
checkedIds={checkedIds}
|
||||
toggleChecked={toggleChecked}
|
||||
allChecked={allChecked}
|
||||
toggleAll={toggleAll}
|
||||
syncedCount={syncedCount}
|
||||
pendingCount={pendingCount}
|
||||
deletionCount={deletionCount}
|
||||
isRunning={isRunning}
|
||||
actionButtonLabel={actionButtonLabel}
|
||||
actionButtonDisabled={actionButtonDisabled}
|
||||
promptSyncSummary={promptSyncSummary}
|
||||
handleMarkCheckedForDeletion={handleMarkCheckedForDeletion}
|
||||
handleToggleSource={handleToggleSource}
|
||||
markForDeletion={markForDeletion}
|
||||
unmarkDeletion={unmarkDeletion}
|
||||
jobStatus={jobStatus}
|
||||
jobDone={jobDone}
|
||||
jobSkip={jobSkip}
|
||||
jobFail={jobFail}
|
||||
jobTotal={jobTotal}
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user