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:
Frank Stellmacher
2026-05-13 15:41:08 +02:00
committed by GitHub
parent c13ee5003f
commit 84c682aeb8
3 changed files with 412 additions and 267 deletions
@@ -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>
);
}