feat(device-sync): overhaul Device Sync page

- New two-panel layout with live album search (search3, 300ms debounce)
  and 10 random albums when search is empty — replaces full paginated load
- Pre-sync summary modal: shows files to add/delete, net change, available
  space; Proceed button disabled when space is insufficient
- calculate_sync_payload now filters already-synced tracks (path exists on
  device) so add_bytes/add_count reflect the true delta
- Space check accounts for pending deletions: addBytes > availableBytes + delBytes
- Separate deviceSyncJobStore for ephemeral job progress state
- Removable drive detection + auto-poll every 5 s via sysinfo
- Desired-state diff: de-selecting a synced source stages it for deletion
  instead of silently removing it
- Status badges (Synced / Pending / Deletion) with matching row highlights
- Live-Search badge () and Zufallsalben section label (⇌) in album browser
- Status summary row height aligned with search field (52 px)
- i18n: all new keys added to all 8 locales (en/de/fr/nl/nb/zh/ru/es)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-14 21:37:35 +02:00
parent 915f0143f7
commit d34de09673
18 changed files with 1912 additions and 279 deletions
+36
View File
@@ -0,0 +1,36 @@
import { create } from 'zustand';
export interface DeviceSyncJobState {
jobId: string | null;
total: number;
done: number;
skipped: number;
failed: number;
status: 'idle' | 'running' | 'done' | 'cancelled';
startSync: (jobId: string, total: number) => void;
updateProgress: (done: number, skipped: number, failed: number) => void;
complete: (done: number, skipped: number, failed: number) => void;
reset: () => void;
}
export const useDeviceSyncJobStore = create<DeviceSyncJobState>()((set) => ({
jobId: null,
total: 0,
done: 0,
skipped: 0,
failed: 0,
status: 'idle',
startSync: (jobId, total) =>
set({ jobId, total, done: 0, skipped: 0, failed: 0, status: 'running' }),
updateProgress: (done, skipped, failed) =>
set({ done, skipped, failed }),
complete: (done, skipped, failed) =>
set({ done, skipped, failed, status: 'done' }),
reset: () =>
set({ jobId: null, total: 0, done: 0, skipped: 0, failed: 0, status: 'idle' }),
}));
+35 -19
View File
@@ -7,21 +7,14 @@ export interface DeviceSyncSource {
name: string;
}
export interface DeviceSyncJob {
id: string;
total: number;
done: number;
skipped: number;
failed: number;
status: 'running' | 'done' | 'cancelled';
}
interface DeviceSyncState {
targetDir: string | null;
filenameTemplate: string;
sources: DeviceSyncSource[]; // persistent device content list
checkedIds: string[]; // currently checked for deletion (not persisted)
activeJob: DeviceSyncJob | null;
checkedIds: string[]; // currently checked for bulk actions (not persisted)
pendingDeletion: string[]; // source IDs marked for deletion (not persisted)
deviceFilePaths: string[]; // actual file paths found on the device (not persisted)
scanning: boolean; // true while scanning the device
setTargetDir: (dir: string | null) => void;
setFilenameTemplate: (t: string) => void;
@@ -30,8 +23,12 @@ interface DeviceSyncState {
clearSources: () => void;
toggleChecked: (id: string) => void;
setCheckedIds: (ids: string[]) => void;
setActiveJob: (job: DeviceSyncJob | null) => void;
updateJob: (update: Partial<DeviceSyncJob>) => void;
markForDeletion: (ids: string[]) => void;
unmarkDeletion: (id: string) => void;
clearPendingDeletion: () => void;
removeSources: (ids: string[]) => void;
setDeviceFilePaths: (paths: string[]) => void;
setScanning: (v: boolean) => void;
}
export const useDeviceSyncStore = create<DeviceSyncState>()(
@@ -41,7 +38,9 @@ export const useDeviceSyncStore = create<DeviceSyncState>()(
filenameTemplate: '{artist}/{album}/{track_number} - {title}',
sources: [],
checkedIds: [],
activeJob: null,
pendingDeletion: [],
deviceFilePaths: [],
scanning: false,
setTargetDir: (dir) => set({ targetDir: dir }),
setFilenameTemplate: (t) => set({ filenameTemplate: t }),
@@ -57,9 +56,10 @@ export const useDeviceSyncStore = create<DeviceSyncState>()(
set((s) => ({
sources: s.sources.filter((x) => x.id !== id),
checkedIds: s.checkedIds.filter((x) => x !== id),
pendingDeletion: s.pendingDeletion.filter((x) => x !== id),
})),
clearSources: () => set({ sources: [], checkedIds: [] }),
clearSources: () => set({ sources: [], checkedIds: [], pendingDeletion: [] }),
toggleChecked: (id) =>
set((s) => ({
@@ -70,12 +70,28 @@ export const useDeviceSyncStore = create<DeviceSyncState>()(
setCheckedIds: (ids) => set({ checkedIds: ids }),
setActiveJob: (job) => set({ activeJob: job }),
updateJob: (update) =>
markForDeletion: (ids) =>
set((s) => ({
activeJob: s.activeJob ? { ...s.activeJob, ...update } : null,
pendingDeletion: [...new Set([...s.pendingDeletion, ...ids])],
checkedIds: s.checkedIds.filter((x) => !ids.includes(x)),
})),
unmarkDeletion: (id) =>
set((s) => ({
pendingDeletion: s.pendingDeletion.filter((x) => x !== id),
})),
clearPendingDeletion: () => set({ pendingDeletion: [] }),
removeSources: (ids) =>
set((s) => ({
sources: s.sources.filter((x) => !ids.includes(x.id)),
checkedIds: s.checkedIds.filter((x) => !ids.includes(x)),
pendingDeletion: s.pendingDeletion.filter((x) => !ids.includes(x)),
})),
setDeviceFilePaths: (paths) => set({ deviceFilePaths: paths }),
setScanning: (v) => set({ scanning: v }),
}),
{
name: 'psysonic_device_sync',