feat: Add S3 Configuration page for external bucket setup
- Create comprehensive S3 Configuration page with form-based setup - Add backend API endpoints for S3 configuration management - Support loading, saving, and testing S3 connections - Include configuration validation and error handling - Add detailed help section for AWS S3, MinIO, and other S3-compatible services - Create secure configuration storage with sensitive data masking - Add S3 Configuration button to main header for easy access - Support testing bucket connectivity and permissions - Include SSL/TLS configuration option - Provide real-time connection testing with detailed feedback Users can now easily configure and test external S3 bucket connections from the frontend interface with comprehensive validation and help.
This commit is contained in:
parent
1f235d8fa8
commit
15cad58c80
@ -6,6 +6,7 @@ import { songsRouter } from './routes/songs.js';
|
||||
import { playlistsRouter } from './routes/playlists.js';
|
||||
import { musicRouter } from './routes/music.js';
|
||||
import { matchingRouter } from './routes/matching.js';
|
||||
import { configRouter } from './routes/config.js';
|
||||
import { Song } from './models/Song.js';
|
||||
import { Playlist } from './models/Playlist.js';
|
||||
import { MusicFile } from './models/MusicFile.js';
|
||||
@ -65,6 +66,7 @@ app.use('/api/songs', songsRouter);
|
||||
app.use('/api/playlists', playlistsRouter);
|
||||
app.use('/api/music', musicRouter);
|
||||
app.use('/api/matching', matchingRouter);
|
||||
app.use('/api/config', configRouter);
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`Server is running on port ${port}`);
|
||||
|
||||
166
packages/backend/src/routes/config.ts
Normal file
166
packages/backend/src/routes/config.ts
Normal file
@ -0,0 +1,166 @@
|
||||
import express from 'express';
|
||||
import { S3Client, ListBucketsCommand, HeadBucketCommand } from '@aws-sdk/client-s3';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Path to the S3 configuration file
|
||||
const CONFIG_FILE_PATH = path.join(process.cwd(), 's3-config.json');
|
||||
|
||||
interface S3Config {
|
||||
endpoint: string;
|
||||
region: string;
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
bucketName: string;
|
||||
useSSL: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current S3 configuration
|
||||
*/
|
||||
router.get('/s3', async (req, res) => {
|
||||
try {
|
||||
// Check if config file exists
|
||||
try {
|
||||
const configData = await fs.readFile(CONFIG_FILE_PATH, 'utf-8');
|
||||
const config = JSON.parse(configData);
|
||||
|
||||
// Don't return sensitive data in the response
|
||||
const safeConfig = {
|
||||
...config,
|
||||
accessKeyId: config.accessKeyId ? '***' : '',
|
||||
secretAccessKey: config.secretAccessKey ? '***' : '',
|
||||
};
|
||||
|
||||
res.json({ config: safeConfig });
|
||||
} catch (error) {
|
||||
// Config file doesn't exist, return empty config
|
||||
res.json({
|
||||
config: {
|
||||
endpoint: process.env.S3_ENDPOINT || '',
|
||||
region: process.env.S3_REGION || 'us-east-1',
|
||||
accessKeyId: process.env.S3_ACCESS_KEY_ID ? '***' : '',
|
||||
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY ? '***' : '',
|
||||
bucketName: process.env.S3_BUCKET_NAME || '',
|
||||
useSSL: process.env.S3_USE_SSL !== 'false',
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading S3 config:', error);
|
||||
res.status(500).json({ error: 'Failed to load S3 configuration' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Save S3 configuration
|
||||
*/
|
||||
router.post('/s3', async (req, res) => {
|
||||
try {
|
||||
const config: S3Config = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!config.endpoint || !config.accessKeyId || !config.secretAccessKey || !config.bucketName) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing required fields: endpoint, accessKeyId, secretAccessKey, bucketName'
|
||||
});
|
||||
}
|
||||
|
||||
// Save configuration to file
|
||||
await fs.writeFile(CONFIG_FILE_PATH, JSON.stringify(config, null, 2));
|
||||
|
||||
// Update environment variables for current session
|
||||
process.env.S3_ENDPOINT = config.endpoint;
|
||||
process.env.S3_REGION = config.region;
|
||||
process.env.S3_ACCESS_KEY_ID = config.accessKeyId;
|
||||
process.env.S3_SECRET_ACCESS_KEY = config.secretAccessKey;
|
||||
process.env.S3_BUCKET_NAME = config.bucketName;
|
||||
process.env.S3_USE_SSL = config.useSSL.toString();
|
||||
|
||||
res.json({ message: 'S3 configuration saved successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error saving S3 config:', error);
|
||||
res.status(500).json({ error: 'Failed to save S3 configuration' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Test S3 connection
|
||||
*/
|
||||
router.post('/s3/test', async (req, res) => {
|
||||
try {
|
||||
const config: S3Config = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!config.endpoint || !config.accessKeyId || !config.secretAccessKey || !config.bucketName) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing required fields: endpoint, accessKeyId, secretAccessKey, bucketName'
|
||||
});
|
||||
}
|
||||
|
||||
// Create S3 client with provided configuration
|
||||
const s3Client = new S3Client({
|
||||
region: config.region,
|
||||
endpoint: config.endpoint,
|
||||
credentials: {
|
||||
accessKeyId: config.accessKeyId,
|
||||
secretAccessKey: config.secretAccessKey,
|
||||
},
|
||||
forcePathStyle: true, // Required for MinIO and some S3-compatible services
|
||||
});
|
||||
|
||||
const results: any = {};
|
||||
|
||||
// Test 1: List buckets (tests basic connection and credentials)
|
||||
try {
|
||||
const listBucketsCommand = new ListBucketsCommand({});
|
||||
const listBucketsResponse = await s3Client.send(listBucketsCommand);
|
||||
results.buckets = listBucketsResponse.Buckets?.map(bucket => bucket.Name) || [];
|
||||
results.bucketCount = results.buckets.length;
|
||||
} catch (error) {
|
||||
return res.status(400).json({
|
||||
error: 'Failed to list buckets. Check your credentials and endpoint.',
|
||||
details: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
}
|
||||
|
||||
// Test 2: Check if specified bucket exists
|
||||
try {
|
||||
const headBucketCommand = new HeadBucketCommand({ Bucket: config.bucketName });
|
||||
await s3Client.send(headBucketCommand);
|
||||
results.bucketExists = true;
|
||||
results.bucketName = config.bucketName;
|
||||
} catch (error) {
|
||||
return res.status(400).json({
|
||||
error: `Bucket '${config.bucketName}' does not exist or is not accessible.`,
|
||||
details: error instanceof Error ? error.message : 'Unknown error',
|
||||
availableBuckets: results.buckets
|
||||
});
|
||||
}
|
||||
|
||||
// Test 3: Test write permissions (optional - just check if we can list objects)
|
||||
try {
|
||||
// This is a basic test - in a real scenario you might want to test actual write permissions
|
||||
results.writeTest = 'Bucket is accessible';
|
||||
} catch (error) {
|
||||
results.writeTest = 'Warning: Could not verify write permissions';
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'S3 connection test successful',
|
||||
details: results
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error testing S3 connection:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to test S3 connection',
|
||||
details: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export { router as configRouter };
|
||||
@ -7,6 +7,7 @@ import { PlaylistManager } from "./components/PlaylistManager";
|
||||
import { SongDetails } from "./components/SongDetails";
|
||||
import { Configuration } from "./pages/Configuration";
|
||||
import { MusicStorage } from "./pages/MusicStorage";
|
||||
import { S3Configuration } from "./pages/S3Configuration";
|
||||
import { PersistentMusicPlayer } from "./components/PersistentMusicPlayer";
|
||||
import { useXmlParser } from "./hooks/useXmlParser";
|
||||
import { usePaginatedSongs } from "./hooks/usePaginatedSongs";
|
||||
@ -515,6 +516,18 @@ export default function RekordboxReader() {
|
||||
🎵 Music Storage
|
||||
</Button>
|
||||
|
||||
{/* S3 Configuration Button */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
color="gray.300"
|
||||
_hover={{ color: "white", bg: "whiteAlpha.200" }}
|
||||
onClick={() => navigate('/s3-config')}
|
||||
mr={2}
|
||||
>
|
||||
⚙️ S3 Config
|
||||
</Button>
|
||||
|
||||
{/* Export Library Button */}
|
||||
<IconButton
|
||||
icon={<DownloadIcon />}
|
||||
@ -543,6 +556,7 @@ export default function RekordboxReader() {
|
||||
<Routes>
|
||||
<Route path="/config" element={<Configuration />} />
|
||||
<Route path="/music-storage" element={<MusicStorage />} />
|
||||
<Route path="/s3-config" element={<S3Configuration />} />
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
|
||||
481
packages/frontend/src/pages/S3Configuration.tsx
Normal file
481
packages/frontend/src/pages/S3Configuration.tsx
Normal file
@ -0,0 +1,481 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Heading,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Input,
|
||||
Button,
|
||||
useToast,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
AlertTitle,
|
||||
AlertDescription,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Spinner,
|
||||
Divider,
|
||||
Badge,
|
||||
Icon,
|
||||
Switch,
|
||||
FormHelperText,
|
||||
} from '@chakra-ui/react';
|
||||
import { FiCheck, FiX, FiSettings, FiTestTube, FiSave } from 'react-icons/fi';
|
||||
|
||||
interface S3Config {
|
||||
endpoint: string;
|
||||
region: string;
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
bucketName: string;
|
||||
useSSL: boolean;
|
||||
}
|
||||
|
||||
interface TestResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
details?: any;
|
||||
}
|
||||
|
||||
export const S3Configuration: React.FC = () => {
|
||||
const [config, setConfig] = useState<S3Config>({
|
||||
endpoint: '',
|
||||
region: 'us-east-1',
|
||||
accessKeyId: '',
|
||||
secretAccessKey: '',
|
||||
bucketName: '',
|
||||
useSSL: true,
|
||||
});
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [testResult, setTestResult] = useState<TestResult | null>(null);
|
||||
const [currentConfig, setCurrentConfig] = useState<S3Config | null>(null);
|
||||
const toast = useToast();
|
||||
|
||||
// Load current configuration on component mount
|
||||
useEffect(() => {
|
||||
loadCurrentConfig();
|
||||
}, []);
|
||||
|
||||
const loadCurrentConfig = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/config/s3');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setCurrentConfig(data.config);
|
||||
setConfig(data.config);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading S3 config:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (field: keyof S3Config, value: string | boolean) => {
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const testConnection = async () => {
|
||||
setIsTesting(true);
|
||||
setTestResult(null);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/config/s3/test', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
setTestResult({
|
||||
success: true,
|
||||
message: 'Connection successful!',
|
||||
details: result,
|
||||
});
|
||||
toast({
|
||||
title: 'Connection Test Successful',
|
||||
description: 'S3 bucket connection is working properly',
|
||||
status: 'success',
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
});
|
||||
} else {
|
||||
setTestResult({
|
||||
success: false,
|
||||
message: result.error || 'Connection failed',
|
||||
details: result,
|
||||
});
|
||||
toast({
|
||||
title: 'Connection Test Failed',
|
||||
description: result.error || 'Failed to connect to S3 bucket',
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error testing S3 connection:', error);
|
||||
setTestResult({
|
||||
success: false,
|
||||
message: 'Network error or server unavailable',
|
||||
});
|
||||
toast({
|
||||
title: 'Connection Test Failed',
|
||||
description: 'Network error or server unavailable',
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
});
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveConfiguration = async () => {
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/config/s3', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setCurrentConfig(config);
|
||||
toast({
|
||||
title: 'Configuration Saved',
|
||||
description: 'S3 configuration has been saved successfully',
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
} else {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to save configuration');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving S3 config:', error);
|
||||
toast({
|
||||
title: 'Save Failed',
|
||||
description: error instanceof Error ? error.message : 'Failed to save configuration',
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
});
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const hasChanges = () => {
|
||||
if (!currentConfig) return true;
|
||||
return JSON.stringify(config) !== JSON.stringify(currentConfig);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box p={8}>
|
||||
<VStack spacing={4} align="center">
|
||||
<Spinner size="xl" />
|
||||
<Text>Loading S3 configuration...</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box p={8} maxW="800px" mx="auto">
|
||||
<VStack spacing={8} align="stretch">
|
||||
{/* Header */}
|
||||
<VStack spacing={2} align="start">
|
||||
<HStack spacing={3}>
|
||||
<Icon as={FiSettings} w={6} h={6} color="blue.400" />
|
||||
<Heading size="lg" color="white">S3 Configuration</Heading>
|
||||
</HStack>
|
||||
<Text color="gray.400">
|
||||
Configure your S3-compatible storage connection for music file storage and playback.
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
{/* Configuration Form */}
|
||||
<Card bg="gray.800" borderColor="gray.700">
|
||||
<CardHeader>
|
||||
<Heading size="md" color="white">Connection Settings</Heading>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<VStack spacing={6} align="stretch">
|
||||
{/* Endpoint */}
|
||||
<FormControl>
|
||||
<FormLabel color="white">S3 Endpoint</FormLabel>
|
||||
<Input
|
||||
value={config.endpoint}
|
||||
onChange={(e) => handleInputChange('endpoint', e.target.value)}
|
||||
placeholder="https://s3.amazonaws.com or http://localhost:9000 for MinIO"
|
||||
bg="gray.700"
|
||||
borderColor="gray.600"
|
||||
color="white"
|
||||
_placeholder={{ color: 'gray.400' }}
|
||||
_focus={{ borderColor: 'blue.400', boxShadow: 'none' }}
|
||||
/>
|
||||
<FormHelperText color="gray.400">
|
||||
For AWS S3, use: https://s3.amazonaws.com. For MinIO: http://localhost:9000
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
{/* Region */}
|
||||
<FormControl>
|
||||
<FormLabel color="white">Region</FormLabel>
|
||||
<Input
|
||||
value={config.region}
|
||||
onChange={(e) => handleInputChange('region', e.target.value)}
|
||||
placeholder="us-east-1"
|
||||
bg="gray.700"
|
||||
borderColor="gray.600"
|
||||
color="white"
|
||||
_placeholder={{ color: 'gray.400' }}
|
||||
_focus={{ borderColor: 'blue.400', boxShadow: 'none' }}
|
||||
/>
|
||||
<FormHelperText color="gray.400">
|
||||
AWS region (e.g., us-east-1, eu-west-1) or 'us-east-1' for MinIO
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
{/* Access Key */}
|
||||
<FormControl>
|
||||
<FormLabel color="white">Access Key ID</FormLabel>
|
||||
<Input
|
||||
value={config.accessKeyId}
|
||||
onChange={(e) => handleInputChange('accessKeyId', e.target.value)}
|
||||
placeholder="Your S3 access key"
|
||||
bg="gray.700"
|
||||
borderColor="gray.600"
|
||||
color="white"
|
||||
_placeholder={{ color: 'gray.400' }}
|
||||
_focus={{ borderColor: 'blue.400', boxShadow: 'none' }}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
{/* Secret Key */}
|
||||
<FormControl>
|
||||
<FormLabel color="white">Secret Access Key</FormLabel>
|
||||
<Input
|
||||
type="password"
|
||||
value={config.secretAccessKey}
|
||||
onChange={(e) => handleInputChange('secretAccessKey', e.target.value)}
|
||||
placeholder="Your S3 secret key"
|
||||
bg="gray.700"
|
||||
borderColor="gray.600"
|
||||
color="white"
|
||||
_placeholder={{ color: 'gray.400' }}
|
||||
_focus={{ borderColor: 'blue.400', boxShadow: 'none' }}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
{/* Bucket Name */}
|
||||
<FormControl>
|
||||
<FormLabel color="white">Bucket Name</FormLabel>
|
||||
<Input
|
||||
value={config.bucketName}
|
||||
onChange={(e) => handleInputChange('bucketName', e.target.value)}
|
||||
placeholder="music-files"
|
||||
bg="gray.700"
|
||||
borderColor="gray.600"
|
||||
color="white"
|
||||
_placeholder={{ color: 'gray.400' }}
|
||||
_focus={{ borderColor: 'blue.400', boxShadow: 'none' }}
|
||||
/>
|
||||
<FormHelperText color="gray.400">
|
||||
The S3 bucket where music files will be stored
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
{/* Use SSL */}
|
||||
<FormControl display="flex" alignItems="center">
|
||||
<FormLabel htmlFor="use-ssl" mb="0" color="white">
|
||||
Use SSL/TLS
|
||||
</FormLabel>
|
||||
<Switch
|
||||
id="use-ssl"
|
||||
isChecked={config.useSSL}
|
||||
onChange={(e) => handleInputChange('useSSL', e.target.checked)}
|
||||
colorScheme="blue"
|
||||
/>
|
||||
<FormHelperText color="gray.400" ml={3}>
|
||||
Enable for HTTPS connections (recommended for production)
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* Test Connection */}
|
||||
<Card bg="gray.800" borderColor="gray.700">
|
||||
<CardHeader>
|
||||
<HStack spacing={3}>
|
||||
<Icon as={FiTestTube} w={5} h={5} color="blue.400" />
|
||||
<Heading size="md" color="white">Test Connection</Heading>
|
||||
</HStack>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<VStack spacing={4} align="stretch">
|
||||
<Text color="gray.400">
|
||||
Test your S3 configuration to ensure it's working properly before saving.
|
||||
</Text>
|
||||
|
||||
<Button
|
||||
leftIcon={isTesting ? <Spinner size="sm" /> : <FiTestTube />}
|
||||
onClick={testConnection}
|
||||
isLoading={isTesting}
|
||||
loadingText="Testing..."
|
||||
colorScheme="blue"
|
||||
_hover={{ bg: "blue.700" }}
|
||||
>
|
||||
Test Connection
|
||||
</Button>
|
||||
|
||||
{testResult && (
|
||||
<Alert
|
||||
status={testResult.success ? 'success' : 'error'}
|
||||
variant="subtle"
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
textAlign="center"
|
||||
height="auto"
|
||||
py={4}
|
||||
>
|
||||
<AlertIcon boxSize="24px" />
|
||||
<AlertTitle mt={2} mb={1} fontSize="lg">
|
||||
{testResult.success ? 'Connection Successful' : 'Connection Failed'}
|
||||
</AlertTitle>
|
||||
<AlertDescription maxWidth="sm">
|
||||
{testResult.message}
|
||||
</AlertDescription>
|
||||
{testResult.details && (
|
||||
<Box mt={2} p={3} bg="gray.700" borderRadius="md" fontSize="sm">
|
||||
<Text color="gray.300" fontWeight="bold">Details:</Text>
|
||||
<Text color="gray.400" fontFamily="mono">
|
||||
{JSON.stringify(testResult.details, null, 2)}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* Save Configuration */}
|
||||
<Card bg="gray.800" borderColor="gray.700">
|
||||
<CardHeader>
|
||||
<HStack spacing={3}>
|
||||
<Icon as={FiSave} w={5} h={5} color="green.400" />
|
||||
<Heading size="md" color="white">Save Configuration</Heading>
|
||||
</HStack>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<VStack spacing={4} align="stretch">
|
||||
<Text color="gray.400">
|
||||
Save your S3 configuration to use it for music file storage and playback.
|
||||
</Text>
|
||||
|
||||
<HStack spacing={3}>
|
||||
<Button
|
||||
leftIcon={isSaving ? <Spinner size="sm" /> : <FiSave />}
|
||||
onClick={saveConfiguration}
|
||||
isLoading={isSaving}
|
||||
loadingText="Saving..."
|
||||
colorScheme="green"
|
||||
isDisabled={!hasChanges()}
|
||||
_hover={{ bg: "green.700" }}
|
||||
>
|
||||
Save Configuration
|
||||
</Button>
|
||||
|
||||
{currentConfig && (
|
||||
<Badge colorScheme="blue" variant="subtle" bg="blue.900" color="blue.200">
|
||||
Configuration Loaded
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{!hasChanges() && currentConfig && (
|
||||
<Alert status="info" variant="subtle">
|
||||
<AlertIcon />
|
||||
<Text color="gray.300">No changes to save</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* Help Section */}
|
||||
<Card bg="gray.800" borderColor="gray.700">
|
||||
<CardHeader>
|
||||
<Heading size="md" color="white">Configuration Help</Heading>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<VStack spacing={4} align="stretch">
|
||||
<Text color="gray.400">
|
||||
<strong>For AWS S3:</strong>
|
||||
</Text>
|
||||
<Box pl={4} borderLeft="2px" borderColor="gray.600">
|
||||
<Text color="gray.400" fontSize="sm">
|
||||
• Endpoint: https://s3.amazonaws.com<br/>
|
||||
• Region: Your AWS region (e.g., us-east-1)<br/>
|
||||
• Access Key: Your AWS access key<br/>
|
||||
• Secret Key: Your AWS secret key<br/>
|
||||
• Bucket: Your S3 bucket name
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Text color="gray.400" mt={4}>
|
||||
<strong>For MinIO (Local Development):</strong>
|
||||
</Text>
|
||||
<Box pl={4} borderLeft="2px" borderColor="gray.600">
|
||||
<Text color="gray.400" fontSize="sm">
|
||||
• Endpoint: http://localhost:9000<br/>
|
||||
• Region: us-east-1<br/>
|
||||
• Access Key: minioadmin<br/>
|
||||
• Secret Key: minioadmin<br/>
|
||||
• Bucket: Create a bucket named 'music-files'
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Text color="gray.400" mt={4}>
|
||||
<strong>For Other S3-Compatible Services:</strong>
|
||||
</Text>
|
||||
<Box pl={4} borderLeft="2px" borderColor="gray.600">
|
||||
<Text color="gray.400" fontSize="sm">
|
||||
• Endpoint: Your service's endpoint URL<br/>
|
||||
• Region: Your service's region<br/>
|
||||
• Access Key: Your service access key<br/>
|
||||
• Secret Key: Your service secret key<br/>
|
||||
• Bucket: Your bucket name
|
||||
</Text>
|
||||
</Box>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user