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:
parent
b436d1dabc
commit
d231073fe0
@ -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>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user