refactor(utils): split componentHelpers grab-bag to owning layers

Per-file routing of the 9-file utils/componentHelpers grab-bag, each driven by
its verified consumer set:
- contextMenu{Actions,Helpers} → features/contextMenu/utils (sole consumer)
- queuePanelHelpers → features/queue/utils (sole consumer)
- nowPlayingHelpers → features/nowPlaying/utils; isRealArtistImage split out to
  cover/ (consumed by cover/artistHero, a lower layer)
- appUpdaterHelpers(+test) + listReorder(+test) → lib/util (config/ + lib/hooks
  consume them; pure)
- playlistDetailHelpers → lib/format (consumed by 3 features; pure)
- appShellHelpers → app/ (app-shell consumers only)
- userMgmtHelpers left in place: consumed by the a11y-HELD SongInfoModal, so a
  move would rewrite a do-not-touch file (same block as licensesData).

Pure moves; tests pass unmodified.
This commit is contained in:
Psychotoxical
2026-06-30 21:05:16 +02:00
parent f43dcc0e76
commit fbca1831a1
46 changed files with 56 additions and 56 deletions
@@ -8,7 +8,7 @@ import { showToast } from '@/lib/dom/toast';
import {
confirmAddAllDuplicates,
isSmartPlaylistName,
} from '@/utils/componentHelpers/contextMenuHelpers';
} from '@/features/contextMenu/utils/contextMenuHelpers';
interface Props {
songIds: string[];
@@ -12,7 +12,7 @@ import {
downloadAlbum as downloadAlbumAction,
startInstantMix as startInstantMixAction,
startRadio as startRadioAction,
} from '@/utils/componentHelpers/contextMenuActions';
} from '@/features/contextMenu/utils/contextMenuActions';
import { useContextMenuKeyboardNav } from '@/features/contextMenu/hooks/useContextMenuKeyboardNav';
import { useContextMenuRating } from '@/features/contextMenu/hooks/useContextMenuRating';
import { usePlaybackLibraryNavigate } from '@/features/playback/hooks/usePlaybackLibraryNavigate';
@@ -8,7 +8,7 @@ import { showToast } from '@/lib/dom/toast';
import {
confirmAddAllDuplicates,
isSmartPlaylistName,
} from '@/utils/componentHelpers/contextMenuHelpers';
} from '@/features/contextMenu/utils/contextMenuHelpers';
interface Props {
albumIds: string[];
@@ -9,7 +9,7 @@ import { showToast } from '@/lib/dom/toast';
import {
confirmAddAllDuplicates,
isSmartPlaylistName,
} from '@/utils/componentHelpers/contextMenuHelpers';
} from '@/features/contextMenu/utils/contextMenuHelpers';
interface Props {
artistIds: string[];
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { ListMusic, Plus } from 'lucide-react';
import { usePlaylistStore } from '@/features/playlist';
import { showToast } from '@/lib/dom/toast';
import { isSmartPlaylistName } from '@/utils/componentHelpers/contextMenuHelpers';
import { isSmartPlaylistName } from '@/features/contextMenu/utils/contextMenuHelpers';
interface SingleProps {
playlist: { id: string; name: string };
@@ -0,0 +1,156 @@
import { join } from '@tauri-apps/api/path';
import { invoke } from '@tauri-apps/api/core';
import { getSimilarSongs2, fetchSimilarTracksRouted, getTopSongs } from '@/lib/api/subsonicArtists';
import { filterSongsForLuckyMixRatings, getMixMinRatingsConfigFromAuth } from '@/utils/mix/mixRatingFilter';
import { buildDownloadUrl } from '@/lib/api/subsonicStreamUrl';
import { useAuthStore } from '@/store/authStore';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import type { Track } from '@/lib/media/trackTypes';
import { resolveQueueTrack } from '@/features/playback/store/queueTrackView';
import { useZipDownloadStore } from '@/features/offline';
import { useDownloadModalStore } from '@/features/offline';
import type { EntityShareKind } from '@/utils/share/shareLink';
import { copyEntityShareLink } from '@/utils/share/copyEntityShareLink';
import { sanitizeFilename, shuffleArray } from '@/features/contextMenu/utils/contextMenuHelpers';
import { songToTrack } from '@/lib/media/songToTrack';
import { showToast } from '@/lib/dom/toast';
export async function copyShareLink(
kind: EntityShareKind,
id: string,
t: (key: string) => string,
) {
const ok = await copyEntityShareLink(kind, id);
if (ok) showToast(t('contextMenu.shareCopied'));
else showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
}
export async function startRadio(
artistId: string,
artistName: string,
playTrack: (track: Track, queue: Track[]) => void,
seedTrack?: Track,
) {
if (seedTrack) {
const state = usePlayerStore.getState();
if (state.currentTrack?.id === seedTrack.id) {
if (!state.isPlaying) state.resume();
} else {
playTrack(seedTrack, [seedTrack]);
}
try {
const [similar, top] = await Promise.all([getSimilarSongs2(artistId), getTopSongs(artistName)]);
const similarTracks = shuffleArray(
similar.map(songToTrack).filter(t => t.id !== seedTrack.id).map(t => ({ ...t, radioAdded: true as const })),
);
const radioTracks = similarTracks.length > 0
? similarTracks
: shuffleArray(
top.map(songToTrack).filter(t => t.id !== seedTrack.id).map(t => ({ ...t, radioAdded: true as const })),
);
if (radioTracks.length > 0) usePlayerStore.getState().enqueueRadio(radioTracks, artistId);
} catch (e) {
console.error('Failed to load radio queue', e);
}
return;
}
// Artist radio without seed
const similarPromise = getSimilarSongs2(artistId).catch(() => [] as Awaited<ReturnType<typeof getSimilarSongs2>>);
try {
const top = await getTopSongs(artistName);
const topTracks = shuffleArray(
top.map(t => ({ ...songToTrack(t), radioAdded: true as const })),
);
if (topTracks.length === 0) {
const similar = await similarPromise;
const fallback = shuffleArray(
similar.map(t => ({ ...songToTrack(t), radioAdded: true as const })),
);
if (fallback.length === 0) return;
const state = usePlayerStore.getState();
if (state.currentTrack) {
state.enqueueRadio(fallback, artistId);
} else {
state.setRadioArtistId(artistId);
playTrack(fallback[0], fallback);
}
return;
}
const state = usePlayerStore.getState();
if (state.currentTrack) {
state.enqueueRadio([topTracks[0]], artistId);
} else {
state.setRadioArtistId(artistId);
playTrack(topTracks[0], [topTracks[0]]);
}
similarPromise.then(similar => {
const similarTracks = shuffleArray(
similar
.map(t => ({ ...songToTrack(t), radioAdded: true as const }))
.filter(t => t.id !== topTracks[0].id),
);
if (similarTracks.length === 0) return;
const { queueItems, queueIndex } = usePlayerStore.getState();
// Thin-state: resolve the upcoming radio refs (cache-warm window) back to
// Tracks so they merge with the new similars in enqueueRadio.
const pendingRadio = queueItems
.slice(queueIndex + 1)
.filter(r => r.radioAdded)
.map(r => resolveQueueTrack(r));
usePlayerStore.getState().enqueueRadio([...pendingRadio, ...similarTracks], artistId);
});
} catch (e) {
console.error('Failed to start radio', e);
}
}
export async function startInstantMix(
song: Track,
t: (key: string) => string,
) {
usePlayerStore.getState().reseedQueueForInstantMix(song);
const serverId = useAuthStore.getState().activeServerId;
try {
const similar = await fetchSimilarTracksRouted(song.id, 50);
if (serverId) useAuthStore.getState().setAudiomuseNavidromeIssue(serverId, false);
const mixCfg = getMixMinRatingsConfigFromAuth();
const ratedFiltered = await filterSongsForLuckyMixRatings(
similar.filter(s => s.id !== song.id),
mixCfg,
);
const shuffled = shuffleArray(
ratedFiltered.map(s => ({ ...songToTrack(s), radioAdded: true as const })),
);
if (shuffled.length > 0) {
const aid = song.artistId?.trim() || undefined;
usePlayerStore.getState().enqueueRadio(shuffled, aid);
}
} catch (e) {
console.error('Instant mix failed', e);
if (serverId) useAuthStore.getState().setAudiomuseNavidromeIssue(serverId, true);
showToast(t('contextMenu.instantMixFailed'), 5000, 'error');
}
}
export async function downloadAlbum(albumName: string, albumId: string) {
const auth = useAuthStore.getState();
const requestDownloadFolder = useDownloadModalStore.getState().requestFolder;
const folder = auth.downloadFolder || await requestDownloadFolder();
if (!folder) return;
const filename = `${sanitizeFilename(albumName)}.zip`;
const destPath = await join(folder, filename);
const url = buildDownloadUrl(albumId);
const id = crypto.randomUUID();
const { start, complete, fail } = useZipDownloadStore.getState();
start(id, filename);
try {
await invoke('download_zip', { id, url, destPath });
complete(id);
} catch (e) {
fail(id);
console.error('ZIP download failed:', e);
}
}
@@ -0,0 +1,42 @@
import { useConfirmModalStore } from '@/store/confirmModalStore';
/** Psysonic smart playlists (Navidrome); not valid targets for manual add-to-playlist. */
export const SMART_PLAYLIST_PREFIX = 'psy-smart-';
export function isSmartPlaylistName(name: string | undefined | null): boolean {
return (name ?? '').toLowerCase().startsWith(SMART_PLAYLIST_PREFIX);
}
export function sanitizeFilename(name: string): string {
return name
.replace(/[/\\?%*:|"<>]/g, '-')
.replace(/\.{2,}/g, '.')
.replace(/^[\s.]+|[\s.]+$/g, '')
.substring(0, 200) || 'download';
}
/** Fisher-Yates in-place shuffle — returns a new array, does not mutate the input. */
export function shuffleArray<T>(arr: T[]): T[] {
const result = [...arr];
for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[result[i], result[j]] = [result[j], result[i]];
}
return result;
}
/** Ask user before re-adding songs to a playlist when *all* selected ids are
* already present. Returns true → caller should append them as duplicates,
* false → caller should keep today's silent-skip toast behavior. */
export async function confirmAddAllDuplicates(
playlistName: string,
count: number,
t: (key: string, opts?: Record<string, unknown>) => string,
): Promise<boolean> {
return useConfirmModalStore.getState().request({
title: t('playlists.duplicateConfirmTitle'),
message: t('playlists.duplicateConfirmMessage', { count, playlist: playlistName }),
confirmLabel: t('playlists.duplicateConfirmAction'),
cancelLabel: t('common.cancel'),
});
}
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { AudioLines, ChevronRight, Play, Square, X } from 'lucide-react';
import type { ColDef } from '@/hooks/useTracklistColumns';
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
import { codecLabel } from '@/utils/componentHelpers/playlistDetailHelpers';
import { codecLabel } from '@/lib/format/playlistDetailHelpers';
import { formatLastSeen } from '@/utils/componentHelpers/userMgmtHelpers';
import i18n from '@/lib/i18n';
import { formatTrackTime } from '@/lib/format/formatDuration';
@@ -2,7 +2,7 @@ import React, { memo, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Disc3, ExternalLink, Star } from 'lucide-react';
import type { SubsonicAlbum, SubsonicSong } from '@/lib/api/subsonicTypes';
import { formatTotalDuration } from '@/utils/componentHelpers/nowPlayingHelpers';
import { formatTotalDuration } from '@/features/nowPlaying/utils/nowPlayingHelpers';
import { formatTrackTime } from '@/lib/format/formatDuration';
interface AlbumCardProps {
@@ -2,7 +2,8 @@ import React, { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
import { useTranslation } from 'react-i18next';
import { ExternalLink } from 'lucide-react';
import type { SubsonicArtistInfo } from '@/lib/api/subsonicTypes';
import { isRealArtistImage, sanitizeHtml } from '@/utils/componentHelpers/nowPlayingHelpers';
import { isRealArtistImage } from '@/cover/isRealArtistImage';
import { sanitizeHtml } from '@/features/nowPlaying/utils/nowPlayingHelpers';
import CachedImage from '@/ui/CachedImage';
export interface ArtistCardTab {
@@ -1,6 +1,6 @@
import React, { memo } from 'react';
import { useTranslation } from 'react-i18next';
import type { ContributorRow } from '@/utils/componentHelpers/nowPlayingHelpers';
import type { ContributorRow } from '@/features/nowPlaying/utils/nowPlayingHelpers';
interface CreditsCardProps { rows: ContributorRow[]; }
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Calendar, Info } from 'lucide-react';
import { open as shellOpen } from '@tauri-apps/plugin-shell';
import type { BandsintownEvent } from '@/api/bandsintown';
import { isoToParts } from '@/utils/componentHelpers/nowPlayingHelpers';
import { isoToParts } from '@/features/nowPlaying/utils/nowPlayingHelpers';
interface TourCardProps {
artistName: string;
+1 -1
View File
@@ -24,7 +24,7 @@ import {
import {
buildContributorRows,
} from '@/utils/componentHelpers/nowPlayingHelpers';
} from '@/features/nowPlaying/utils/nowPlayingHelpers';
import NpCardWrap from '@/features/nowPlaying/components/NpCardWrap';
import NpColumnEl from '@/features/nowPlaying/components/NpColumnEl';
import RadioView from '@/features/nowPlaying/components/RadioView';
@@ -0,0 +1,66 @@
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
import { sanitizeHtml as sanitizeHtmlBase } from '@/lib/util/sanitizeHtml';
export function formatTime(s: number): string {
if (!s || isNaN(s)) return '0:00';
const m = Math.floor(s / 60);
return `${m}:${Math.floor(s % 60).toString().padStart(2, '0')}`;
}
export function formatCompact(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(n >= 10_000 ? 0 : 1)}K`;
return String(n);
}
export function formatTotalDuration(s: number): string {
if (!s || isNaN(s)) return '—';
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = Math.floor(s % 60);
if (h > 0) return `${h}h ${m}m`;
if (m > 0) return `${m}m ${sec}s`;
return `${sec}s`;
}
/** Shared HTML sanitiser plus a now-playing-specific tweak: strip the trailing
* "Read more on Last.fm" style link so clamped bios end cleanly. */
export function sanitizeHtml(html: string): string {
return sanitizeHtmlBase(html).replace(/<a [^>]*>.*?<\/a>\.?\s*$/i, '').trim();
}
export function isoToParts(iso: string): { month: string; day: string; weekday: string; time: string } | null {
if (!iso) return null;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return null;
return {
month: d.toLocaleString(undefined, { month: 'short' }),
day: String(d.getDate()),
weekday: d.toLocaleString(undefined, { weekday: 'short' }),
time: d.toLocaleString(undefined, { hour: '2-digit', minute: '2-digit' }),
};
}
export interface ContributorRow { role: string; names: string[]; }
export function buildContributorRows(song: SubsonicSong | null | undefined, mainArtistName: string): ContributorRow[] {
if (!song?.contributors || song.contributors.length === 0) return [];
const mainLower = mainArtistName.trim().toLowerCase();
const rows = new Map<string, Set<string>>();
for (const c of song.contributors) {
const role = c.role?.trim();
const name = c.artist?.name?.trim();
if (!role || !name) continue;
const label = c.subRole ? `${role}${c.subRole}` : role;
let bucket = rows.get(label);
if (!bucket) { bucket = new Set(); rows.set(label, bucket); }
bucket.add(name);
}
const out: ContributorRow[] = [];
for (const [role, names] of rows.entries()) {
const list = Array.from(names);
if (role.toLowerCase().startsWith('artist') && list.length === 1 && list[0].toLowerCase() === mainLower) continue;
out.push({ role, names: list });
}
return out;
}
+1 -1
View File
@@ -18,7 +18,7 @@ import {
} from '@/features/offline/utils/offlineLibraryHelpers';
import { librarySqlServerId } from '@/api/coverCache';
import { resolveIndexKey, serverIndexKeyForProfile } from '@/lib/server/serverIndexKey';
import { isSmartPlaylistName } from '@/utils/componentHelpers/playlistDetailHelpers';
import { isSmartPlaylistName } from '@/lib/format/playlistDetailHelpers';
import {
enqueueOfflinePin,
registerOfflinePinExecutor,
@@ -14,7 +14,7 @@ import {
syncAllPinnedPlaylists,
syncPinnedArtistIfNeeded,
} from '@/features/offline/utils/pinnedOfflineSync';
import { SMART_PREFIX } from '@/utils/componentHelpers/playlistDetailHelpers';
import { SMART_PREFIX } from '@/lib/format/playlistDetailHelpers';
const getPlaylistMock = vi.fn();
const getAlbumForServerMock = vi.fn();
@@ -9,7 +9,7 @@ import type { PinSource } from '@/store/localPlaybackStore';
import { useLocalPlaybackStore } from '@/store/localPlaybackStore';
import { useOfflineStore } from '@/features/offline/store/offlineStore';
import { usePlaylistStore } from '@/features/playlist';
import { isSmartPlaylistName } from '@/utils/componentHelpers/playlistDetailHelpers';
import { isSmartPlaylistName } from '@/lib/format/playlistDetailHelpers';
import { getMediaDir } from '@/lib/media/mediaDir';
import {
isActiveServerReachable,
@@ -13,7 +13,7 @@ import { useThemeStore } from '@/store/themeStore';
import { usePlaylistLayoutStore, type PlaylistLayoutItemId } from '@/features/playlist/store/playlistLayoutStore';
import {
displayPlaylistName, formatSize, isSmartPlaylistName, totalDurationLabel,
} from '@/utils/componentHelpers/playlistDetailHelpers';
} from '@/lib/format/playlistDetailHelpers';
import type { CoverArtId } from '@/cover/types';
import { AlbumCoverArtImage } from '@/cover/AlbumCoverArtImage';
import { PLAYLIST_MAIN_COVER_CSS_PX } from '@/features/playlist/hooks/usePlaylistCovers';
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { AudioLines, ChevronRight, Heart, Play, Square, Trash2 } from 'lucide-react';
import type { ColDef } from '@/hooks/useTracklistColumns';
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
import { codecLabel } from '@/utils/componentHelpers/playlistDetailHelpers';
import { codecLabel } from '@/lib/format/playlistDetailHelpers';
import { formatLastSeen } from '@/utils/componentHelpers/userMgmtHelpers';
import i18n from '@/lib/i18n';
import { formatTrackTime } from '@/lib/format/formatDuration';
@@ -12,7 +12,7 @@ import { useThemeStore } from '@/store/themeStore';
import { usePlaylistLayoutStore } from '@/features/playlist/store/playlistLayoutStore';
import { songToTrack } from '@/lib/media/songToTrack';
import { getQueueTracksView } from '@/features/playback/store/queueTrackView';
import { codecLabel } from '@/utils/componentHelpers/playlistDetailHelpers';
import { codecLabel } from '@/lib/format/playlistDetailHelpers';
import { formatLastSeen } from '@/utils/componentHelpers/userMgmtHelpers';
import { formatTrackTime } from '@/lib/format/formatDuration';
import i18n from '@/lib/i18n';
@@ -3,7 +3,7 @@ import { join } from '@tauri-apps/api/path';
import { buildDownloadUrl } from '@/lib/api/subsonicStreamUrl';
import type { SubsonicPlaylist } from '@/lib/api/subsonicTypes';
import { useZipDownloadStore } from '@/features/offline';
import { sanitizeFilename } from '@/utils/componentHelpers/playlistDetailHelpers';
import { sanitizeFilename } from '@/lib/format/playlistDetailHelpers';
export interface RunPlaylistZipDownloadDeps {
playlist: SubsonicPlaylist;
@@ -7,7 +7,7 @@ import type { PlaybackSourceKind } from '@/features/playback/utils/playback/reso
import {
formatQueueReplayGainParts,
renderStars,
} from '@/utils/componentHelpers/queuePanelHelpers';
} from '@/features/queue/utils/queuePanelHelpers';
import { loudnessGainPlaceholderUntilCacheDb } from '@/features/playback/utils/audio/loudnessPlaceholder';
import { effectiveLoudnessPreAnalysisAttenuationDb } from '@/lib/audio/loudnessPreAnalysisSlider';
import { formatQueueBpmTech, formatQueueMoodLabels } from '@/lib/library/trackEnrichment';
@@ -6,7 +6,7 @@ import { usePlayerStore } from '@/features/playback/store/playerStore';
import { useAuthStore } from '@/store/authStore';
import type { QueueItemRef } from '@/lib/media/trackTypes';
import type { QueueDisplayMode } from '@/store/authStoreTypes';
import type { DurationMode } from '@/utils/componentHelpers/queuePanelHelpers';
import type { DurationMode } from '@/features/queue/utils/queuePanelHelpers';
import { formatLongDuration } from '@/lib/format/formatDuration';
import { formatClockTime } from '@/lib/format/formatClockTime';
import { resolveQueueTrack } from '@/features/playback/store/queueTrackView';
@@ -0,0 +1,36 @@
import { Star } from 'lucide-react';
import type { TFunction } from 'i18next';
import type { Track } from '@/lib/media/trackTypes';
export type { DurationMode } from '@/store/authStoreTypes';
export function formatQueueReplayGainParts(track: Track, t: TFunction): string[] {
const parts: string[] = [];
const fmtDb = (db: number) => `${db >= 0 ? '+' : ''}${db.toFixed(1)}`;
if (track.replayGainTrackDb != null) {
parts.push(t('queue.rgTrack', { db: fmtDb(track.replayGainTrackDb) }));
}
if (track.replayGainAlbumDb != null) {
parts.push(t('queue.rgAlbum', { db: fmtDb(track.replayGainAlbumDb) }));
}
if (track.replayGainPeak != null) {
parts.push(t('queue.rgPeak', { pk: track.replayGainPeak.toFixed(3) }));
}
return parts;
}
export function renderStars(rating?: number) {
if (!rating) return null;
const stars = [];
for (let i = 1; i <= 5; i++) {
stars.push(
<Star
key={i}
size={12}
fill={i <= rating ? 'var(--highlight)' : 'none'}
color={i <= rating ? 'var(--highlight)' : 'var(--text-muted)'}
/>
);
}
return <div style={{ display: 'flex', gap: '2px', alignItems: 'center' }}>{stars}</div>;
}
@@ -2,7 +2,7 @@ import { useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useArtistLayoutStore, type ArtistSectionConfig, type ArtistSectionId } from '@/features/artist';
import { useListReorderDnd } from '@/lib/hooks/useListReorderDnd';
import { applyListReorderById, type ListReorderDropTarget } from '@/utils/componentHelpers/listReorder';
import { applyListReorderById, type ListReorderDropTarget } from '@/lib/util/listReorder';
import { ReorderGripHandle } from '@/features/settings/components/ReorderGripHandle';
const ARTIST_SECTION_LABEL_KEYS: Record<ArtistSectionId, string> = {
@@ -4,7 +4,7 @@ import { useShallow } from 'zustand/react/shallow';
import { useAuthStore } from '@/store/authStore';
import type { LyricsSourceId } from '@/store/authStoreTypes';
import { useListReorderDnd } from '@/lib/hooks/useListReorderDnd';
import { applyListReorderById, type ListReorderDropTarget } from '@/utils/componentHelpers/listReorder';
import { applyListReorderById, type ListReorderDropTarget } from '@/lib/util/listReorder';
import { ReorderGripHandle } from '@/features/settings/components/ReorderGripHandle';
import { SettingsToggle } from '@/features/settings/components/SettingsToggle';
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Blend, Infinity as InfinityIcon, ListMusic, MoveRight, Share2, Shuffle, Trash2, Waves } from 'lucide-react';
import { useQueueToolbarStore, QueueToolbarButtonId } from '@/store/queueToolbarStore';
import { useListReorderDnd } from '@/lib/hooks/useListReorderDnd';
import { applyListReorderById, type ListReorderDropTarget } from '@/utils/componentHelpers/listReorder';
import { applyListReorderById, type ListReorderDropTarget } from '@/lib/util/listReorder';
import { ReorderGripHandle } from '@/features/settings/components/ReorderGripHandle';
const QUEUE_TOOLBAR_BUTTON_ICONS: Record<QueueToolbarButtonId, typeof Shuffle | null> = {
@@ -38,7 +38,7 @@ import { switchActiveServer } from '@/utils/server/switchActiveServer';
import { AddServerForm } from '@/features/settings/components/AddServerForm';
import { ServerCapabilityHeaderBadge } from '@/features/settings/components/ServerCapabilityHeaderBadge';
import { useListReorderDnd } from '@/lib/hooks/useListReorderDnd';
import { applyListReorderById, type ListReorderDropTarget } from '@/utils/componentHelpers/listReorder';
import { applyListReorderById, type ListReorderDropTarget } from '@/lib/util/listReorder';
import { ReorderGripHandle } from '@/features/settings/components/ReorderGripHandle';
import { tooltipAttrs } from '@/ui/tooltipAttrs';
@@ -6,7 +6,7 @@ import { useLuckyMixAvailable } from '@/features/randomMix';
import { ALL_NAV_ITEMS } from '@/config/navItems';
import { applySidebarReorderById } from '@/features/sidebar';
import { useListReorderDnd } from '@/lib/hooks/useListReorderDnd';
import type { ListReorderDropTarget } from '@/utils/componentHelpers/listReorder';
import type { ListReorderDropTarget } from '@/lib/util/listReorder';
import { ReorderGripHandle } from '@/features/settings/components/ReorderGripHandle';
import { SettingsGroup } from '@/features/settings/components/SettingsGroup';
import { SettingsToggle } from '@/features/settings/components/SettingsToggle';
@@ -16,7 +16,7 @@ import {
} from '@/lib/themes/themeRegistry';
import { installThemeFromRegistry } from '@/lib/themes/installThemeFromRegistry';
import { uninstallTheme } from '@/lib/themes/uninstallTheme';
import { isNewer } from '@/utils/componentHelpers/appUpdaterHelpers';
import { isNewer } from '@/lib/util/appUpdaterHelpers';
type ModeFilter = 'all' | 'dark' | 'light';
type SortMode = 'newest' | 'name';
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { isNewer } from '@/utils/componentHelpers/appUpdaterHelpers';
import { isNewer } from '@/lib/util/appUpdaterHelpers';
import { fetchRegistry, getCachedRegistry, type Registry, type RegistryTheme } from '@/lib/themes/themeRegistry';
import { useInstalledThemesStore } from '@/store/installedThemesStore';
@@ -1,6 +1,6 @@
import { ALL_NAV_ITEMS } from '@/config/navItems';
import { CONSERVED_SIDEBAR_NAV_IDS, type SidebarItemConfig } from '@/features/sidebar/store/sidebarStore';
import { applyListReorderById, type ListReorderDropTarget } from '@/utils/componentHelpers/listReorder';
import { applyListReorderById, type ListReorderDropTarget } from '@/lib/util/listReorder';
export type SidebarNavSection = 'library' | 'system';
+1 -1
View File
@@ -5,7 +5,7 @@ import { invoke } from '@tauri-apps/api/core';
import { useTranslation } from 'react-i18next';
import { version as currentVersion } from '../../../../package.json';
import { IS_LINUX, IS_MACOS, IS_WINDOWS } from '@/lib/util/platform';
import { SKIP_KEY, isNewer, isWithinModerationWindow, pickAsset, type ReleaseData, type DlState } from '@/utils/componentHelpers/appUpdaterHelpers';
import { SKIP_KEY, isNewer, isWithinModerationWindow, pickAsset, type ReleaseData, type DlState } from '@/lib/util/appUpdaterHelpers';
/** All update-modal state, the GitHub release probe and the download/relaunch
* handlers. The component owns only the early-return guard and the JSX. */