feat(frontend/config): UX improvements

- Disable 'Sync S3' buttons when no S3 config present; add hint
- Remember active config tab via localStorage; enable isLazy Tabs
- Lazy-load Music Library data only when its tab is opened
- Clarify 'Auto-Link' as a cleanup action; disable when nothing to link
- Limit matching tab API calls with ?limit to reduce payloads
This commit is contained in:
Geert Rademakes 2025-08-07 23:41:33 +02:00
parent b436d1dabc
commit d231073fe0
2 changed files with 79 additions and 32 deletions

View File

@ -11,10 +11,6 @@ import {
CardHeader, CardHeader,
Badge, Badge,
Progress, Progress,
Alert,
AlertIcon,
AlertTitle,
AlertDescription,
useToast, useToast,
Modal, Modal,
ModalOverlay, ModalOverlay,
@ -57,7 +53,6 @@ export const SongMatching: React.FC = () => {
const [stats, setStats] = useState<MatchingStats | null>(null); const [stats, setStats] = useState<MatchingStats | null>(null);
const [unmatchedMusicFiles, setUnmatchedMusicFiles] = useState<any[]>([]); const [unmatchedMusicFiles, setUnmatchedMusicFiles] = useState<any[]>([]);
const [matchedMusicFiles, setMatchedMusicFiles] = useState<any[]>([]); const [matchedMusicFiles, setMatchedMusicFiles] = useState<any[]>([]);
const [songsWithoutMusicFiles, setSongsWithoutMusicFiles] = useState<any[]>([]);
const [songsWithMusicFiles, setSongsWithMusicFiles] = useState<any[]>([]); const [songsWithMusicFiles, setSongsWithMusicFiles] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [autoLinking, setAutoLinking] = useState(false); const [autoLinking, setAutoLinking] = useState(false);
@ -75,12 +70,11 @@ export const SongMatching: React.FC = () => {
const loadData = async () => { const loadData = async () => {
setIsLoading(true); setIsLoading(true);
try { try {
const [statsRes, unmatchedRes, matchedRes, songsWithoutRes, songsWithRes] = await Promise.all([ const [statsRes, unmatchedRes, matchedRes, songsWithRes] = await Promise.all([
fetch('/api/matching/stats'), fetch('/api/matching/stats'),
fetch('/api/matching/unmatched-music-files'), fetch('/api/matching/unmatched-music-files?limit=200'),
fetch('/api/matching/matched-music-files'), fetch('/api/matching/matched-music-files?limit=200'),
fetch('/api/matching/songs-without-music-files'), fetch('/api/matching/songs-with-music-files?limit=200')
fetch('/api/matching/songs-with-music-files')
]); ]);
if (statsRes.ok) { if (statsRes.ok) {
@ -98,11 +92,6 @@ export const SongMatching: React.FC = () => {
setMatchedMusicFiles(matchedData.musicFiles); setMatchedMusicFiles(matchedData.musicFiles);
} }
if (songsWithoutRes.ok) {
const songsData = await songsWithoutRes.json();
setSongsWithoutMusicFiles(songsData.songs);
}
if (songsWithRes.ok) { if (songsWithRes.ok) {
const songsData = await songsWithRes.json(); const songsData = await songsWithRes.json();
setSongsWithMusicFiles(songsData.songs); setSongsWithMusicFiles(songsData.songs);
@ -257,12 +246,7 @@ export const SongMatching: React.FC = () => {
return 'red'; return 'red';
}; };
const formatDuration = (seconds: number): string => { const canAutoLink = !!stats && stats.unmatchedMusicFiles > 0;
if (!seconds || isNaN(seconds)) return '00:00';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
if (isLoading) { if (isLoading) {
return ( return (
@ -315,7 +299,7 @@ export const SongMatching: React.FC = () => {
<CardBody> <CardBody>
<VStack spacing={4}> <VStack spacing={4}>
<Text color="gray.300" textAlign="center"> <Text color="gray.300" textAlign="center">
Automatically match and link music files to songs in your Rekordbox library Try linking any remaining unmatched files. The main S3 sync already performs matching; use this for leftovers.
</Text> </Text>
<Button <Button
leftIcon={<FiZap />} leftIcon={<FiZap />}
@ -325,8 +309,9 @@ export const SongMatching: React.FC = () => {
isLoading={autoLinking} isLoading={autoLinking}
loadingText="Auto-Linking..." loadingText="Auto-Linking..."
_hover={{ bg: "blue.700" }} _hover={{ bg: "blue.700" }}
isDisabled={autoLinking || !canAutoLink}
> >
Auto-Link Files Auto-Link Remaining Files
</Button> </Button>
</VStack> </VStack>
</CardBody> </CardBody>

View File

@ -35,7 +35,7 @@ import { S3Configuration } from "./S3Configuration";
import { MusicUpload } from "../components/MusicUpload"; import { MusicUpload } from "../components/MusicUpload";
import { SongMatching } from "../components/SongMatching"; import { SongMatching } from "../components/SongMatching";
import { api } from "../services/api"; import { api } from "../services/api";
import { useState, useEffect } from "react"; import { useState, useEffect, useMemo } from "react";
interface MusicFile { interface MusicFile {
_id: string; _id: string;
@ -60,11 +60,52 @@ export function Configuration() {
const [musicFiles, setMusicFiles] = useState<MusicFile[]>([]); const [musicFiles, setMusicFiles] = useState<MusicFile[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isSyncing, setIsSyncing] = useState(false); const [isSyncing, setIsSyncing] = useState(false);
const [hasS3Config, setHasS3Config] = useState<boolean>(false);
const [musicLoaded, setMusicLoaded] = useState<boolean>(false);
// Load music files on component mount // Tabs: remember active tab across refreshes
useEffect(() => { const initialTabIndex = useMemo(() => {
loadMusicFiles(); const stored = localStorage.getItem('configTabIndex');
return stored ? parseInt(stored, 10) : 0;
}, []); }, []);
const [tabIndex, setTabIndex] = useState<number>(initialTabIndex);
// Tab indices (keep in sync with Tab order below)
const TAB_INDEX = {
LIBRARY: 0,
UPLOAD: 1,
MUSIC_LIBRARY: 2,
MATCHING: 3,
S3_CONFIG: 4,
} as const;
// Fetch S3 config (small and safe to do on mount)
useEffect(() => {
const fetchS3Config = async () => {
try {
const res = await fetch('/api/config/s3');
if (res.ok) {
const data = await res.json();
const cfg = data?.config || {};
const required = ['endpoint', 'region', 'accessKeyId', 'secretAccessKey', 'bucketName'];
const present = required.every((k) => typeof cfg[k] === 'string' && cfg[k]);
setHasS3Config(present);
} else {
setHasS3Config(false);
}
} catch {
setHasS3Config(false);
}
};
fetchS3Config();
}, []);
// Lazy-load: only load music files when Music Library tab becomes active
useEffect(() => {
if (tabIndex === TAB_INDEX.MUSIC_LIBRARY && !musicLoaded) {
loadMusicFiles().then(() => setMusicLoaded(true));
}
}, [tabIndex]);
const loadMusicFiles = async () => { const loadMusicFiles = async () => {
setIsLoading(true); setIsLoading(true);
@ -107,8 +148,13 @@ export function Configuration() {
isClosable: true, isClosable: true,
}); });
// Reload music files to show the new ones // Reload music files to show the new ones if user is on Music Library tab
await loadMusicFiles(); if (tabIndex === TAB_INDEX.MUSIC_LIBRARY) {
await loadMusicFiles();
} else {
// Mark as not loaded so that when user opens the tab, it fetches fresh
setMusicLoaded(false);
}
} else { } else {
throw new Error('Failed to sync S3'); throw new Error('Failed to sync S3');
} }
@ -241,7 +287,16 @@ export function Configuration() {
<Heading size="lg">Configuration</Heading> <Heading size="lg">Configuration</Heading>
</Flex> </Flex>
<Tabs variant="enclosed" colorScheme="blue"> <Tabs
variant="enclosed"
colorScheme="blue"
isLazy
index={tabIndex}
onChange={(index) => {
setTabIndex(index);
localStorage.setItem('configTabIndex', String(index));
}}
>
<TabList bg="gray.800" borderColor="gray.700"> <TabList bg="gray.800" borderColor="gray.700">
<Tab color="gray.300" _selected={{ bg: "gray.700", color: "white", borderColor: "gray.600" }}> <Tab color="gray.300" _selected={{ bg: "gray.700", color: "white", borderColor: "gray.600" }}>
<HStack spacing={2}> <HStack spacing={2}>
@ -351,6 +406,7 @@ export function Configuration() {
colorScheme="blue" colorScheme="blue"
onClick={handleSyncS3} onClick={handleSyncS3}
isLoading={isSyncing} isLoading={isSyncing}
isDisabled={!hasS3Config || isSyncing}
loadingText="Syncing..." loadingText="Syncing..."
_hover={{ bg: "blue.900", borderColor: "blue.400" }} _hover={{ bg: "blue.900", borderColor: "blue.400" }}
> >
@ -359,11 +415,11 @@ export function Configuration() {
</HStack> </HStack>
</HStack> </HStack>
{isLoading ? ( {tabIndex === TAB_INDEX.MUSIC_LIBRARY && isLoading ? (
<Text textAlign="center" color="gray.500"> <Text textAlign="center" color="gray.500">
Loading music files... Loading music files...
</Text> </Text>
) : musicFiles.length === 0 ? ( ) : tabIndex === TAB_INDEX.MUSIC_LIBRARY && musicFiles.length === 0 ? (
<VStack spacing={4} textAlign="center" color="gray.500"> <VStack spacing={4} textAlign="center" color="gray.500">
<Text>No music files found in the database.</Text> <Text>No music files found in the database.</Text>
<Text fontSize="sm"> <Text fontSize="sm">
@ -373,12 +429,18 @@ export function Configuration() {
leftIcon={<FiRefreshCw />} leftIcon={<FiRefreshCw />}
onClick={handleSyncS3} onClick={handleSyncS3}
isLoading={isSyncing} isLoading={isSyncing}
isDisabled={!hasS3Config || isSyncing}
loadingText="Syncing..." loadingText="Syncing..."
colorScheme="blue" colorScheme="blue"
_hover={{ bg: "blue.700" }} _hover={{ bg: "blue.700" }}
> >
Sync S3 Bucket Sync S3 Bucket
</Button> </Button>
{!hasS3Config && (
<Text fontSize="xs" color="orange.300">
Configure your S3 connection in the S3 Configuration tab first.
</Text>
)}
</VStack> </VStack>
) : ( ) : (
<VStack spacing={2} align="stretch"> <VStack spacing={2} align="stretch">