feat: added translations for 4 tabs in Settings screen

This commit is contained in:
Xiaohan-Tian
2026-05-31 00:21:44 -07:00
parent 9fb206c456
commit 59dea65b0d
10 changed files with 415 additions and 113 deletions
@@ -2,6 +2,7 @@ import React from 'react';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import AudioIOSettings from './AudioIOSettings';
import { translate } from '../../../i18n/translate';
const configState = new Map<string, unknown>([
['audio.input_device_id', 'default'],
@@ -63,16 +64,16 @@ describe('AudioIOSettings', () => {
it('renders input/output selectors and refresh action', async () => {
render(<AudioIOSettings />);
expect(await screen.findByText('Audio I/O')).toBeTruthy();
expect(screen.getByLabelText('Audio Input Device')).toBeTruthy();
expect(screen.getByLabelText('Audio Output Device')).toBeTruthy();
expect(screen.getByText('Refresh Device List')).toBeTruthy();
expect(await screen.findByText(translate('settings.audioIo.title', undefined, 'en_us'))).toBeTruthy();
expect(screen.getByLabelText(translate('settings.audioIo.inputDevice', undefined, 'en_us'))).toBeTruthy();
expect(screen.getByLabelText(translate('settings.audioIo.outputDevice', undefined, 'en_us'))).toBeTruthy();
expect(screen.getByText(translate('settings.audioIo.refresh', undefined, 'en_us'))).toBeTruthy();
});
it('persists input device changes', async () => {
render(<AudioIOSettings />);
const select = await screen.findByLabelText('Audio Input Device');
const select = await screen.findByLabelText(translate('settings.audioIo.inputDevice', undefined, 'en_us'));
fireEvent.change(select, { target: { value: 'mic-1' } });
await waitFor(() => {
@@ -7,8 +7,10 @@ import {
supportsAudioContextSinkSelection,
type AudioDeviceOption,
} from '../../../util/audioDeviceUtil';
import { useI18n } from '../../../i18n/useI18n';
const AudioIOSettings: React.FC = () => {
const { t } = useI18n();
const [inputDeviceId, setInputDeviceId] = useState<string>('default');
const [outputDeviceId, setOutputDeviceId] = useState<string>('default');
const [inputs, setInputs] = useState<AudioDeviceOption[]>([getDefaultAudioDeviceOption('audioinput')]);
@@ -67,7 +69,7 @@ const AudioIOSettings: React.FC = () => {
...snapshot.inputs,
{
deviceId: configuredInputId,
label: 'Previously Selected Input (permission required to verify)',
label: t('settings.audioIo.previousSelectedInput'),
kind: 'audioinput' as const,
isDefault: false,
},
@@ -78,7 +80,7 @@ const AudioIOSettings: React.FC = () => {
...snapshot.outputs,
{
deviceId: configuredOutputId,
label: 'Previously Selected Output (permission required to verify)',
label: t('settings.audioIo.previousSelectedOutput'),
kind: 'audiooutput' as const,
isDefault: false,
},
@@ -91,7 +93,7 @@ const AudioIOSettings: React.FC = () => {
if (!hasInput && configuredInputId !== 'default' && snapshot.labelsAvailable) {
setInputDeviceId('default');
await configManager.set('audio.input_device_id', 'default');
setDeviceStatus('Previously selected audio input device is unavailable; using System Default.');
setDeviceStatus(t('settings.audioIo.status.inputUnavailable'));
} else {
setInputDeviceId(hasInput || !snapshot.labelsAvailable ? configuredInputId : 'default');
}
@@ -99,17 +101,17 @@ const AudioIOSettings: React.FC = () => {
if (!hasOutput && configuredOutputId !== 'default' && snapshot.labelsAvailable) {
setOutputDeviceId('default');
await configManager.set('audio.output_device_id', 'default');
setDeviceStatus('Previously selected audio output device is unavailable; using System Default.');
setDeviceStatus(t('settings.audioIo.status.outputUnavailable'));
} else {
setOutputDeviceId(hasOutput || !snapshot.labelsAvailable ? configuredOutputId : 'default');
}
if (showStatus) {
setDeviceStatus(current => current || 'Audio device list refreshed.');
setDeviceStatus(current => current || t('settings.audioIo.status.refreshed'));
}
} catch (error) {
console.error('Unable to refresh audio devices:', error);
setDeviceStatus('Unable to read audio devices from the browser.');
setDeviceStatus(t('settings.audioIo.status.readFailed'));
} finally {
setRefreshing(false);
}
@@ -119,63 +121,63 @@ const AudioIOSettings: React.FC = () => {
setInputDeviceId(value);
await configManager.set('audio.input_device_id', value);
setDeviceStatus(value === 'default'
? 'Audio input will use System Default on the next recording session.'
: 'Audio input will change on the next recording session.');
? t('settings.audioIo.status.inputDefaultNextSession')
: t('settings.audioIo.status.inputChangedNextSession'));
};
const handleOutputDeviceChange = async (value: string) => {
setOutputDeviceId(value);
await configManager.set('audio.output_device_id', value);
setDeviceStatus(value === 'default'
? 'Audio output will use System Default after refresh.'
: 'Audio output device saved. Refresh the page to apply it in v1.');
? t('settings.audioIo.status.outputDefaultAfterRefresh')
: t('settings.audioIo.status.outputSavedRefreshRequired'));
};
const handleChooseOutputDevice = async () => {
try {
const selectedDevice = await promptForAudioOutputDevice();
if (!selectedDevice) {
setDeviceStatus('This browser does not support prompting for audio output devices.');
setDeviceStatus(t('settings.audioIo.status.outputPromptUnsupported'));
return;
}
setOutputDeviceId(selectedDevice.deviceId);
await configManager.set('audio.output_device_id', selectedDevice.deviceId);
await refreshDevices(false);
setDeviceStatus('Output device selected. Refresh the page to apply it in v1.');
setDeviceStatus(t('settings.audioIo.status.outputSelectedRefreshRequired'));
} catch (error) {
console.error('Unable to choose audio output device:', error);
setDeviceStatus('The browser did not allow selecting a non-default output device.');
setDeviceStatus(t('settings.audioIo.status.outputSelectionDenied'));
}
};
return (
<div className="settings-section">
<div className="settings-section-header">
<h3>Audio I/O</h3>
<h3>{t('settings.audioIo.title')}</h3>
</div>
<div className="settings-section-content">
<div className="settings-group">
<div className="settings-group-header">
<h4>Device Routing</h4>
<h4>{t('settings.audioIo.deviceRouting')}</h4>
<button
type="button"
className="settings-btn"
onClick={() => void refreshDevices()}
disabled={refreshing}
>
{refreshing ? 'Refreshing' : 'Refresh Device List'}
{refreshing ? t('settings.audioIo.refreshing') : t('settings.audioIo.refresh')}
</button>
</div>
<div className="settings-description">
Choose the devices KGStudio should use for recording and playback. Input changes apply to the next recording session. Output changes require a page refresh in v1.
{t('settings.audioIo.description')}
</div>
<div className="settings-item">
<label className="settings-label" htmlFor="audio-input-device-select">
Audio Input Device
{t('settings.audioIo.inputDevice')}
</label>
<select
id="audio-input-device-select"
@@ -191,13 +193,13 @@ const AudioIOSettings: React.FC = () => {
))}
</select>
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
Changes apply to the next audio recording. If the selected device is removed, KGStudio will fall back to System Default.
{t('settings.audioIo.inputHelp')}
</div>
</div>
<div className="settings-item">
<label className="settings-label" htmlFor="audio-output-device-select">
Audio Output Device
{t('settings.audioIo.outputDevice')}
</label>
<select
id="audio-output-device-select"
@@ -215,16 +217,16 @@ const AudioIOSettings: React.FC = () => {
{supportsOutputPrompt && (
<div style={{ marginTop: '10px' }}>
<button type="button" className="settings-btn" onClick={() => void handleChooseOutputDevice()}>
Choose Output Device
{t('settings.audioIo.chooseOutputDevice')}
</button>
</div>
)}
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
Output-device changes require refresh in v1. Browser support for non-default output routing is limited, so KGStudio will continue on System Default when unsupported.
{t('settings.audioIo.outputHelp')}
</div>
{!supportsOutputSink && (
<div className="settings-help" style={{ fontSize: '12px', color: '#d0a56b', marginTop: '6px' }}>
This browser does not expose reliable live Web Audio sink switching. Non-default output selection is best-effort and may remain on System Default.
{t('settings.audioIo.outputSinkUnsupported')}
</div>
)}
</div>
@@ -5,8 +5,10 @@ import {
normalizeSpectrogramHeightResolution,
type SpectrogramHeightResolution,
} from '../../../util/spectrogramUtil';
import { useI18n } from '../../../i18n/useI18n';
const BehaviorSettings: React.FC = () => {
const { t } = useI18n();
const [playheadUpdateFrequency, setPlayheadUpdateFrequency] = useState<number>(30);
const [spectrogramHeightResolution, setSpectrogramHeightResolution] = useState<SpectrogramHeightResolution>(3);
const [chatboxDefaultOpen, setChatboxDefaultOpen] = useState<boolean>(true);
@@ -78,11 +80,11 @@ const BehaviorSettings: React.FC = () => {
// Validate the input
if (isNaN(numValueMs)) {
errors.push('Lookahead time must be a valid number');
errors.push(t('settings.behavior.validation.lookahead.invalidNumber'));
} else if (numValueSeconds < 0) {
errors.push('Lookahead time must be between 0 and 0.5 seconds (0-500ms)');
errors.push(t('settings.behavior.validation.lookahead.range'));
} else if (numValueSeconds > 0.5) {
errors.push('Lookahead time must be between 0 and 0.5 seconds (0-500ms)');
errors.push(t('settings.behavior.validation.lookahead.range'));
}
setLookaheadValidationErrors(errors);
@@ -108,11 +110,11 @@ const BehaviorSettings: React.FC = () => {
// Validate the input
if (isNaN(numValueMs)) {
errors.push('Playback delay must be a valid number');
errors.push(t('settings.behavior.validation.playbackDelay.invalidNumber'));
} else if (numValueSeconds < 0) {
errors.push('Playback delay must be between 0 and 0.5 seconds (0-500ms)');
errors.push(t('settings.behavior.validation.playbackDelay.range'));
} else if (numValueSeconds > 0.5) {
errors.push('Playback delay must be between 0 and 0.5 seconds (0-500ms)');
errors.push(t('settings.behavior.validation.playbackDelay.range'));
}
setPlaybackDelayValidationErrors(errors);
@@ -138,11 +140,11 @@ const BehaviorSettings: React.FC = () => {
const errors: string[] = [];
if (isNaN(numValueMs)) {
errors.push('MIDI input latency must be a valid number');
errors.push(t('settings.behavior.validation.recordingOffset.invalidNumber'));
} else if (numValueSeconds < 0) {
errors.push('MIDI input latency must be between 0 and 0.5 seconds (0-500ms)');
errors.push(t('settings.behavior.validation.recordingOffset.range'));
} else if (numValueSeconds > 0.5) {
errors.push('MIDI input latency must be between 0 and 0.5 seconds (0-500ms)');
errors.push(t('settings.behavior.validation.recordingOffset.range'));
}
setRecordingOffsetValidationErrors(errors);
@@ -169,16 +171,16 @@ const BehaviorSettings: React.FC = () => {
return (
<div className="settings-section">
<div className="settings-section-header">
<h3>Behavior</h3>
<h3>{t('settings.behavior.title')}</h3>
</div>
<div className="settings-section-content">
<div className="settings-group">
<h4>Editor</h4>
<h4>{t('settings.behavior.editor')}</h4>
<div className="settings-item">
<label className="settings-label">
Playhead Update Frequency (fps)
{t('settings.behavior.playheadUpdateFrequency')}
</label>
<select
className="settings-select"
@@ -190,13 +192,13 @@ const BehaviorSettings: React.FC = () => {
<option value="60">60</option>
</select>
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
Update frequency for the playhead animation during playback. Higher values (60 fps) provide smoother animation but use more CPU. Lower values (10 fps) are more efficient. Changes apply immediately without restart.
{t('settings.behavior.playheadUpdateFrequencyHelp')}
</div>
</div>
<div className="settings-item">
<label className="settings-label">
Spectrogram Height Resolution
{t('settings.behavior.spectrogramHeightResolution')}
</label>
<select
className="settings-select"
@@ -208,35 +210,35 @@ const BehaviorSettings: React.FC = () => {
<option value="5">5x</option>
</select>
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
Controls vertical spectrogram detail across the visible pitch range. Higher values sharpen pitch contours but use more CPU and memory while computing the spectrogram.
{t('settings.behavior.spectrogramHeightResolutionHelp')}
</div>
</div>
</div>
<div className="settings-group">
<h4>Chat Box</h4>
<h4>{t('settings.behavior.chatBox')}</h4>
<div className="settings-item">
<label className="settings-label">
Open at Start Up
{t('settings.behavior.openAtStartup')}
</label>
<select
className="settings-select"
value={chatboxDefaultOpen ? 'yes' : 'no'}
onChange={(e) => handleChatboxDefaultOpenChange(e.target.value)}
>
<option value="no">No</option>
<option value="yes">Yes</option>
<option value="no">{t('settings.no')}</option>
<option value="yes">{t('settings.yes')}</option>
</select>
</div>
</div>
<div className="settings-group">
<h4>Audio</h4>
<h4>{t('settings.behavior.audio')}</h4>
<div className="settings-item">
<label className="settings-label">
Lookahead Time (ms)
{t('settings.behavior.lookaheadTime')}
</label>
<input
type="number"
@@ -257,13 +259,13 @@ const BehaviorSettings: React.FC = () => {
</div>
)}
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
Audio scheduling lookahead time (0-500ms). Lower values (10-20ms) reduce MIDI input latency but may cause audio glitches on slower systems. Higher values (100ms+) are better for playback stability. Changes apply immediately without restart.
{t('settings.behavior.lookaheadTimeHelp')}
</div>
</div>
<div className="settings-item">
<label className="settings-label">
Playback Delay (ms)
{t('settings.behavior.playbackDelay')}
</label>
<input
type="number"
@@ -284,31 +286,31 @@ const BehaviorSettings: React.FC = () => {
</div>
)}
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
Playback will start with a short delay after pressing the start button (0-500ms). Increasing this value might help stabilize playback, especially for the first few ticks if the lookahead value is too low. Changes apply immediately without restart.
{t('settings.behavior.playbackDelayHelp')}
</div>
</div>
<div className="settings-item">
<label className="settings-label">
MIDI Automation Interpolation
{t('settings.behavior.midiAutomationInterpolation')}
</label>
<select
className="settings-select"
value={midiAutomationInterpolationIntervalMs}
onChange={(e) => handleMidiAutomationInterpolationIntervalChange(e.target.value)}
>
<option value="20">Low-end (20 ms)</option>
<option value="10">Balanced (10 ms)</option>
<option value="5">High quality (5 ms)</option>
<option value="20">{t('settings.behavior.midiAutomationInterpolation.low')}</option>
<option value="10">{t('settings.behavior.midiAutomationInterpolation.balanced')}</option>
<option value="5">{t('settings.behavior.midiAutomationInterpolation.high')}</option>
</select>
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
Controls how densely MIDI automation are baked for playback and bounce. Smaller intervals sound smoother but schedule more events.
{t('settings.behavior.midiAutomationInterpolationHelp')}
</div>
</div>
<div className="settings-item">
<label className="settings-label">
MIDI Input Latency (ms)
{t('settings.behavior.midiInputLatency')}
</label>
<input
type="number"
@@ -329,43 +331,43 @@ const BehaviorSettings: React.FC = () => {
</div>
)}
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
Timing correction for MIDI recording (0-500ms). If recorded notes appear slightly late compared to where you intended to play them, increase this value to match your MIDI device's input latency. Each note's position is shifted back by this amount when committed.
{t('settings.behavior.midiInputLatencyHelp')}
</div>
</div>
<div className="settings-item">
<label className="settings-label">
Bounce Starts From Beat 1
{t('settings.behavior.bounceStartsFromBeat1')}
</label>
<select
className="settings-select"
value={bounceStartsFromBeat1 ? 'yes' : 'no'}
onChange={(e) => handleBounceStartsFromBeat1Change(e.target.value)}
>
<option value="no">No</option>
<option value="yes">Yes</option>
<option value="no">{t('settings.no')}</option>
<option value="yes">{t('settings.yes')}</option>
</select>
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
Yes includes leading silence from the start of the song up to the first rendered region when bouncing WAV/MP3. No trims that leading silence and starts bounce at the first rendered note or audio region.
{t('settings.behavior.bounceStartsFromBeat1Help')}
</div>
</div>
<div className="settings-item">
<label className="settings-label">
Capture Audio for Screen Sharing
{t('settings.behavior.captureAudioForScreenSharing')}
</label>
<select
className="settings-select"
value={enableAudioCapture ? 'yes' : 'no'}
onChange={(e) => handleEnableAudioCaptureChange(e.target.value)}
>
<option value="no">No</option>
<option value="yes">Yes</option>
<option value="no">{t('settings.no')}</option>
<option value="yes">{t('settings.yes')}</option>
</select>
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
Restart KGStudio (refresh the page) to take effect. Enable this option when KGStudio's audio cannot be captured during screen sharing in video calls (e.g., Zoom, Teams). This creates an additional audio stream that screen capture applications can detect.
{t('settings.behavior.captureAudioForScreenSharingHelp')}
<br />
<b>It is important to make sure when screen sharing in Zoom, the "Share Sound" option is enabled.</b>
<b>{t('settings.behavior.captureAudioForScreenSharingWarning')}</b>
</div>
</div>
</div>
@@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import ChordGuideSettings from './ChordGuideSettings';
import chordGuideDataJson from '../../../../public/resources/modes/chord_guide.json';
import type { ChordGuideData } from '../../../core/ChordGuideTypes';
import { translate } from '../../../i18n/translate';
const chordGuideData = chordGuideDataJson as ChordGuideData;
@@ -41,7 +42,9 @@ vi.mock('../../../core/KGCore', () => ({
}));
describe('ChordGuideSettings', () => {
const getGroup = (heading: 'Major Candidate Chords' | 'Minor Candidate Chords') => (
const majorHeading = translate('settings.chordGuide.group.major', undefined, 'en_us');
const minorHeading = translate('settings.chordGuide.group.minor', undefined, 'en_us');
const getGroup = (heading: string) => (
screen.getByText(heading).closest('.settings-group') as HTMLElement
);
@@ -67,17 +70,19 @@ describe('ChordGuideSettings', () => {
it('renders major and minor groups with base-key guidance', async () => {
render(<ChordGuideSettings />);
expect(await screen.findByText('Major Candidate Chords')).toBeTruthy();
expect(screen.getByText('Minor Candidate Chords')).toBeTruthy();
expect(screen.getByText(/relative to C major/i)).toBeTruthy();
expect(screen.getByText(/relative to A minor/i)).toBeTruthy();
expect(await screen.findByText(majorHeading)).toBeTruthy();
expect(screen.getByText(minorHeading)).toBeTruthy();
expect(screen.getByText(translate('settings.chordGuide.help.major', undefined, 'en_us'))).toBeTruthy();
expect(screen.getByText(translate('settings.chordGuide.help.minor', undefined, 'en_us'))).toBeTruthy();
expect(screen.getByText(translate('settings.chordGuide.resetToDefault', undefined, 'en_us'))).toBeTruthy();
expect(screen.getAllByRole('columnheader', { name: translate('settings.chordGuide.table.chord', undefined, 'en_us') })).toHaveLength(2);
});
it('switches T/S/D tabs as a mutex control', async () => {
render(<ChordGuideSettings />);
await screen.findByText('Major Candidate Chords');
const majorGroup = getGroup('Major Candidate Chords');
await screen.findByText(majorHeading);
const majorGroup = getGroup(majorHeading);
expect(within(majorGroup).getByTitle('C')).toBeTruthy();
fireEvent.click(within(majorGroup).getByRole('button', { name: 'S' }));
@@ -89,8 +94,8 @@ describe('ChordGuideSettings', () => {
it('adds and deletes rows in the active table', async () => {
render(<ChordGuideSettings />);
await screen.findByText('Major Candidate Chords');
const addButtons = screen.getAllByTitle(/Add .* chord/);
await screen.findByText(majorHeading);
const addButtons = screen.getAllByTitle(translate('settings.chordGuide.addTitle', { group: majorHeading }, 'en_us'));
fireEvent.click(addButtons[0]);
await waitFor(() => {
@@ -106,7 +111,7 @@ describe('ChordGuideSettings', () => {
const rows = screen.getAllByRole('row');
fireEvent.click(rows[1]);
const deleteButtons = screen.getAllByTitle('Delete selected rows');
const deleteButtons = screen.getAllByTitle(translate('settings.chordGuide.deleteSelectedRows', undefined, 'en_us'));
fireEvent.click(deleteButtons[0]);
await waitFor(() => {
@@ -117,8 +122,8 @@ describe('ChordGuideSettings', () => {
it('updates notes and source after a valid chord edit', async () => {
render(<ChordGuideSettings />);
await screen.findByText('Major Candidate Chords');
const majorGroup = getGroup('Major Candidate Chords');
await screen.findByText(majorHeading);
const majorGroup = getGroup(majorHeading);
fireEvent.click(within(majorGroup).getByRole('button', { name: 'S' }));
const chordCell = within(majorGroup).getByTitle('F');
@@ -142,7 +147,7 @@ describe('ChordGuideSettings', () => {
it('rejects invalid chord edits', async () => {
render(<ChordGuideSettings />);
await screen.findByText('Major Candidate Chords');
await screen.findByText(majorHeading);
const chordCell = screen.getAllByTitle('C')[0];
fireEvent.doubleClick(chordCell);
const input = screen.getByDisplayValue('C');
@@ -150,14 +155,14 @@ describe('ChordGuideSettings', () => {
fireEvent.keyDown(input, { key: 'Enter' });
await waitFor(() => {
expect(showAlertMock).toHaveBeenCalledWith(expect.stringContaining('valid chord symbol'));
expect(showAlertMock).toHaveBeenCalledWith(translate('settings.chordGuide.invalidChordSymbol', undefined, 'en_us'));
});
});
it('enforces the 128-character note limit', async () => {
render(<ChordGuideSettings />);
await screen.findByText('Major Candidate Chords');
await screen.findByText(majorHeading);
const noteCell = screen.getAllByRole('cell').find((cell) => cell.textContent === chordGuideData.ionian.T[0].note) as HTMLElement;
fireEvent.doubleClick(noteCell);
const input = screen.getByDisplayValue(chordGuideData.ionian.T[0].note);
@@ -194,7 +199,7 @@ describe('ChordGuideSettings', () => {
render(<ChordGuideSettings />);
expect((await screen.findAllByTitle('G')).length).toBeGreaterThan(0);
fireEvent.click(screen.getByText('Reset to Default'));
fireEvent.click(screen.getByText(translate('settings.chordGuide.resetToDefault', undefined, 'en_us')));
await waitFor(() => {
expect(configManagerMock.set).toHaveBeenCalledWith('chord_guide.custom_items', null);
@@ -19,6 +19,7 @@ import {
import { parseChordSymbol } from '../../../util/chordUtil';
import { showAlert } from '../../../util/dialogUtil';
import { isModifierKeyPressed } from '../../../util/osUtil';
import { useI18n } from '../../../i18n/useI18n';
type FunctionType = 'T' | 'S' | 'D';
type EditableColumn = 'name' | 'note';
@@ -36,16 +37,6 @@ const FUNCTION_BUTTONS: Array<{ value: FunctionType; label: string; title: strin
{ value: 'D', label: 'D', title: 'Dominant' },
];
const GROUP_LABELS: Record<ChordGuideGroupKey, string> = {
major: 'Major Candidate Chords',
minor: 'Minor Candidate Chords',
};
const GROUP_HELP: Record<ChordGuideGroupKey, string> = {
major: 'Enter chords relative to C major. The app will transpose them automatically for other major key signatures.',
minor: 'Enter chords relative to A minor. The app will transpose them automatically for other minor key signatures.',
};
const CONFIG_KEY = 'chord_guide.custom_items';
function cloneModeDefinition(definition: ChordGuideModeDefinition): ChordGuideModeDefinition {
@@ -86,6 +77,7 @@ async function loadBundledChordGuideData(): Promise<ChordGuideData> {
}
const ChordGuideSettings: React.FC = () => {
const { t } = useI18n();
const configManager = ConfigManager.instance();
const [defaultsData, setDefaultsData] = React.useState<ChordGuideData | null>(null);
const [customConfig, setCustomConfig] = React.useState<ChordGuideCustomConfig | null>(null);
@@ -115,9 +107,9 @@ const ChordGuideSettings: React.FC = () => {
void initialize().catch((error) => {
console.error('Failed to initialize chord guide settings:', error);
void showAlert('Failed to load chord guide settings.');
void showAlert(t('settings.chordGuide.loadError'));
});
}, [configManager]);
}, [configManager, t]);
React.useEffect(() => {
if (!editingCell) {
@@ -180,12 +172,12 @@ const ChordGuideSettings: React.FC = () => {
const trimmedValue = editingCell.value.trim();
if (column === 'name') {
if (!trimmedValue || parseChordSymbol(trimmedValue) === null) {
await showAlert('Please enter a valid chord symbol. Example: Bm7b5');
await showAlert(t('settings.chordGuide.invalidChordSymbol'));
return;
}
const derived = buildDerivedChordGuideItem(group, { name: trimmedValue, note: row.note });
if (!derived) {
await showAlert('Unable to derive notes for this chord. Please use a supported chord symbol.');
await showAlert(t('settings.chordGuide.unsupportedChordSymbol'));
return;
}
await updateGroupRows(group, functionType, (rows) => rows.map((candidate, index) => (
@@ -290,14 +282,16 @@ const ChordGuideSettings: React.FC = () => {
const rows = getModeDefinition(customConfig, group)[functionType];
const selection = selectedRows[group];
const groupLabel = t(`settings.chordGuide.group.${group}`);
return (
<div className="settings-group" key={group}>
<div className="settings-group-header">
<h4>{GROUP_LABELS[group]}</h4>
<h4>{groupLabel}</h4>
</div>
<div className="settings-description">{GROUP_HELP[group]}</div>
<div className="settings-description">{t(`settings.chordGuide.help.${group}`)}</div>
<div className="event-list-tabs" role="tablist" aria-label={`${GROUP_LABELS[group]} functions`}>
<div className="event-list-tabs" role="tablist" aria-label={t('settings.chordGuide.functionsAria', { group: groupLabel })}>
{FUNCTION_BUTTONS.map((button) => (
<button
key={button.value}
@@ -319,7 +313,7 @@ const ChordGuideSettings: React.FC = () => {
<div className="event-list-toolbar-group">
<button
className="event-list-add-button settings-chord-guide-add-button"
title={`Add ${GROUP_LABELS[group]} chord`}
title={t('settings.chordGuide.addTitle', { group: groupLabel })}
type="button"
onClick={() => { void handleAddRow(group); }}
>
@@ -329,7 +323,7 @@ const ChordGuideSettings: React.FC = () => {
<div className="event-list-toolbar-group event-list-toolbar-group-right">
<button
className="event-list-delete-button"
title="Delete selected rows"
title={t('settings.chordGuide.deleteSelectedRows')}
type="button"
onClick={() => { void handleDeleteRows(group); }}
disabled={selection.size === 0}
@@ -343,10 +337,10 @@ const ChordGuideSettings: React.FC = () => {
<table className="event-list-table">
<thead>
<tr>
<th>Chord</th>
<th>Notes</th>
<th>Source</th>
<th>Note</th>
<th>{t('settings.chordGuide.table.chord')}</th>
<th>{t('settings.chordGuide.table.notes')}</th>
<th>{t('settings.chordGuide.table.source')}</th>
<th>{t('settings.chordGuide.table.note')}</th>
</tr>
</thead>
<tbody>
@@ -415,13 +409,13 @@ const ChordGuideSettings: React.FC = () => {
return (
<div className="settings-section">
<div className="settings-section-header">
<h3>Chord Guide</h3>
<h3>{t('settings.chordGuide.title')}</h3>
</div>
<div className="settings-section-content">
<div className="settings-help-links settings-chord-guide-reset-row">
<button className="settings-help" type="button" onClick={() => { void handleResetToDefault(); }}>
Reset to Default
{t('settings.chordGuide.resetToDefault')}
</button>
</div>
{renderGroup('major')}
@@ -1,7 +1,9 @@
import React, { useState, useEffect, useCallback } from 'react';
import { ConfigManager } from '../../../core/config/ConfigManager';
import { useI18n } from '../../../i18n/useI18n';
const TemplatesSettings: React.FC = () => {
const { t } = useI18n();
const [customInstructions, setCustomInstructions] = useState<string>('');
const configManager = ConfigManager.instance();
@@ -42,17 +44,17 @@ const TemplatesSettings: React.FC = () => {
return (
<div className="settings-section">
<div className="settings-section-header">
<h3>Templates</h3>
<h3>{t('settings.templates.title')}</h3>
</div>
<div className="settings-section-content">
<div className="settings-group">
<h4>Custom Instructions</h4>
<h4>{t('settings.templates.customInstructions')}</h4>
<div className="settings-item">
<textarea
className="settings-textarea"
placeholder="Please input your custom instructions for the K.G.Studio Musician Assistant"
placeholder={t('settings.templates.placeholder')}
rows={8}
value={customInstructions}
onChange={(e) => handleCustomInstructionsChange(e.target.value)}
@@ -64,4 +66,4 @@ const TemplatesSettings: React.FC = () => {
);
};
export default TemplatesSettings;
export default TemplatesSettings;