mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-20 15:20:39 -07:00
feat(i18n): add i18next foundation with English + zh-CN locales (#508)
* feat(i18n): add i18next foundation with English + zh-CN locales Installs i18next + react-i18next + language detector and wires up a language selector in the General settings page. Extracts strings from the highest-visibility surfaces: all settings tabs, model management, sidebar nav, main editor, and the floating generate box. Remaining strings (profile forms, history, stories/effects/voices/audio tabs) can land in follow-up PRs. Closes #411. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * fix(i18n): ensure language switch actually re-renders the tree - `nonExplicitSupportedLngs: true` was normalizing `zh-CN` → `zh` in some code paths; since we have explicit `zh-CN` resources, swap it for `load: 'currentOnly'` which keeps the code as-is. - `react: { useSuspense: false }` — react-i18next v17 defaults Suspense on, which can silently suspend components mid-switch and look like "nothing happens" to the user. - Use `i18n.language` (the raw current code) instead of `resolvedLanguage` in the selector so the dropdown always mirrors what we just set. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * feat(i18n): localize ProfileCard, HistoryTable, and relative dates - ProfileCard: "No description", "designed" badge, aria-labels, and delete dialog. - HistoryTable: delete / clear-failed / import / effects dialogs. - formatDate: switch date-fns `formatDistance` locale based on `i18n.language` so "5 minutes ago" becomes "5 分钟前" under zh-CN. HistoryTable now subscribes via useTranslation so the table re-renders when language flips. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * feat(i18n): localize Stories tab (list, content, dialogs, toasts) Covers the title + "New Story" button, empty states, story row metadata (item count, updated time), the create/edit/delete dialogs, and all toast notifications. Also handles StoryContent: "Select a story" placeholder, search popover, "Export Audio" button, and the "Generating N audios" pending indicator. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * feat(i18n): localize history item and story item dropdown menus Covers the "..." action menu on both the History table (Play, Export Audio, Export Package, Apply Effects, Regenerate, Delete) and on individual story chat items (Play from here, Remove from Story). Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * feat(i18n): localize Effects tab (list, detail, dialogs, toasts) Covers EffectsList (title, "New Preset", section headers, preset cards) and EffectsDetail (header buttons for Save / Save as Custom / Delete, name/description fields, preview section, Save as Custom dialog, all toasts). Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * feat(i18n): localize EffectsChainEditor and built-in preset names - Effect type labels (Chorus/Flanger, Reverb, Delay, Compressor, Gain, High-Pass, Low-Pass, Pitch Shift) and every param label (LFO speed, Modulation depth, Threshold, Ratio, etc.) go through `effects.types.<type>.{label,params.<param>}` with the backend string as defaultValue fallback. - Chain-level controls: "Load preset…", "Add effect…", "Clear", Power/Remove button titles. - Built-in preset names + descriptions (Robotic, Radio, Echo Chamber, Deep Voice) are translated client-side; user-created presets keep their original names. Backend keeps returning English — frontend intercepts and translates via key lookup, defaulting to the backend string so unknown effects/params don't break. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * feat(i18n): localize Create/Edit Voice modal and audio sample panels ProfileForm now routes its title, description, voice-source toggle (Clone from audio / Built-in voice), field labels (Name, Description, Language, Engine, Voice, Reference Text, Default Engine, Default Effects), sample tabs (Upload / Record / System Audio), action buttons, and every toast + Zod validation message through i18n. Also covers the three AudioSample panels (Upload/Record/System) — the choose-file / start-recording / start-capture call-to-actions, the "N remaining" countdown, "Recording complete" / "Capture complete" states, and the Play / Transcribe / Remove / Record Again buttons. SampleList too — the "No samples yet" empty state, per-sample edit mode, mini-player aria labels, Delete Sample dialog, and toasts. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * feat(i18n): localize Audio Channels tab (list, dialogs, device picker) Covers the "Audio Channels" title and "New Channel" button, the empty state, per-channel section labels (Output Devices / Assigned Voices), the Available Devices right pane with its three contextual hints, the "No voices assigned" fallback, and both Create/Edit dialogs (titles, descriptions, field labels, Select placeholders, and the "(default)" badge). Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * feat(i18n): localize Voices tab (table header, search, inspector) VoicesTab now translates the "Voices" title, search placeholder, "New Voice" button, all six table column headers (Name, Language, Generations, Samples, Effects, Channels), the avatar alt text, and the per-row channel MultiSelect (placeholder + "(Default)" suffix). VoiceInspector routes its form labels through the existing `profileForm.fields.*` keys, has its own "Default Effects" hint and avatar/save toasts, and reuses the ProfileForm Zod validation. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * feat(i18n): localize ProfileList unsupported-model note and empty state Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * feat(i18n): add Traditional Chinese (zh-TW) locale Adds a zh-TW translation with Taiwan vocabulary conventions (e.g. 預設 / 儲存 / 載入 / 匯入 / 匯出 / 設定 / 檔案 / 伺服器 / 裝置 / 網路). Registers it alongside en and zh-CN; the language dropdown picks it up automatically from SUPPORTED_LANGUAGES. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * feat(i18n): add Japanese (ja) locale Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * fix(i18n): localize relative dates for ja and zh-TW formatDate only mapped zh-CN, so history timestamps stayed in English for ja and zh-TW users even after the rest of the UI translated. Extend the switch to ja and zhTW from date-fns/locale. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * fix(i18n): address PR review feedback Bugs: - GeneralPage: network access toast used the keep-server-running title key (wrong semantic scope). Add networkAccess.updatedTitle and use it. - GeneralPage: fallback "Unknown" version was stored as a translated string in state, so it stayed stale across language switches. Store null, resolve the label at render time. - GeneralPage: memoize the zod resolver on t and retrigger validation when the locale changes so existing error messages retranslate. - GpuPage: adding t to the CUDA progress EventSource effect deps caused the SSE connection to be torn down and reopened on every language change, potentially dropping in-flight download events. Capture t in a ref. - HistoryTable: Effects dialog still rendered English "Source" / "Select source version" / "Cancel" / "Apply" / "Applying..." — localize them. - Locales: zh-CN / zh-TW / ja devSuffix was missing the leading space before "(开发版)"/"(開發版)"/"(開発版)", so dev builds rendered "v0.4.2(开发版)" instead of "v0.4.2 (开发版)". Nits: - ModelManagement: rename .find((t) => ...) callback param to avoid shadowing useTranslation().t. - GenerationPage: rename chunkLimit.value interpolation key from count → chars so i18next doesn't silently activate pluralization if a translator later adds _one/_other forms. - LanguageSelect: narrow onValueChange handler param to LanguageCode. Key count now 559 across en/zh-CN/zh-TW/ja (added 4 effectsDialog keys plus networkAccess.updatedTitle). Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
21dd3b8315
commit
a72ef81dc1
@@ -1,6 +1,7 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Check, CheckCircle2, Edit, Plus, Speaker, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -33,6 +34,7 @@ interface AudioDevice {
|
||||
}
|
||||
|
||||
export function AudioTab() {
|
||||
const { t } = useTranslation();
|
||||
const platform = usePlatform();
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [editingChannel, setEditingChannel] = useState<string | null>(null);
|
||||
@@ -119,14 +121,14 @@ export function AudioTab() {
|
||||
if (channelsLoading || devicesLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-muted-foreground">Loading...</div>
|
||||
<div className="text-muted-foreground">{t('audioChannels.loading')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleChannelDelete = async (e: React.MouseEvent, channelId: string) => {
|
||||
e.stopPropagation();
|
||||
if (await confirm('Delete this channel?')) {
|
||||
if (await confirm(t('audioChannels.confirmDelete'))) {
|
||||
deleteChannel.mutate(channelId);
|
||||
}
|
||||
};
|
||||
@@ -140,10 +142,10 @@ export function AudioTab() {
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex items-center justify-between mb-6 shrink-0">
|
||||
<h2 className="text-2xl font-bold">Audio Channels</h2>
|
||||
<h2 className="text-2xl font-bold">{t('audioChannels.title')}</h2>
|
||||
<Button onClick={() => setCreateDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
New Channel
|
||||
{t('audioChannels.newChannel')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -158,13 +160,10 @@ export function AudioTab() {
|
||||
{allChannels.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 border-2 border-dashed border-muted rounded-md">
|
||||
<Speaker className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground mb-4">
|
||||
No audio channels yet. Create your first channel to route voices to specific
|
||||
devices.
|
||||
</p>
|
||||
<p className="text-muted-foreground mb-4">{t('audioChannels.empty.message')}</p>
|
||||
<Button onClick={() => setCreateDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create Channel
|
||||
{t('audioChannels.empty.action')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -195,7 +194,7 @@ export function AudioTab() {
|
||||
<div className="space-y-2.5 ml-10">
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1">
|
||||
Output Devices
|
||||
{t('audioChannels.labels.outputDevices')}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{channel.device_ids.length > 0
|
||||
@@ -224,7 +223,7 @@ export function AudioTab() {
|
||||
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1">
|
||||
Assigned Voices
|
||||
{t('audioChannels.labels.assignedVoices')}
|
||||
</div>
|
||||
<ChannelVoicesList channelId={channel.id} />
|
||||
</div>
|
||||
@@ -270,13 +269,13 @@ export function AudioTab() {
|
||||
)}
|
||||
>
|
||||
<div className="shrink-0 mb-4">
|
||||
<h3 className="text-lg font-semibold">Available Devices</h3>
|
||||
<h3 className="text-lg font-semibold">{t('audioChannels.devices.title')}</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{selectedChannelId
|
||||
? selectedChannel?.is_default
|
||||
? 'Default channel uses system default device'
|
||||
: 'Click devices to add or remove them from the selected channel'
|
||||
: 'Select a channel to assign devices'}
|
||||
? t('audioChannels.devices.defaultNote')
|
||||
: t('audioChannels.devices.toggleHint')
|
||||
: t('audioChannels.devices.selectHint')}
|
||||
</p>
|
||||
</div>
|
||||
{allDevices.length > 0 ? (
|
||||
@@ -344,8 +343,8 @@ export function AudioTab() {
|
||||
<CheckCircle2 className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground text-center">
|
||||
{platform.metadata.isTauri
|
||||
? 'No audio devices found'
|
||||
: 'Audio device selection requires Tauri'}
|
||||
? t('audioChannels.devices.empty')
|
||||
: t('audioChannels.devices.requiresTauri')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -394,6 +393,7 @@ export function AudioTab() {
|
||||
}
|
||||
|
||||
function ChannelVoicesList({ channelId }: { channelId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { data: voices } = useQuery({
|
||||
queryKey: ['channel-voices', channelId],
|
||||
queryFn: () => apiClient.getChannelVoices(channelId),
|
||||
@@ -416,7 +416,7 @@ function ChannelVoicesList({ channelId }: { channelId: string }) {
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">No voices assigned</span>
|
||||
<span className="text-sm text-muted-foreground">{t('audioChannels.noVoicesAssigned')}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -430,6 +430,7 @@ interface CreateChannelDialogProps {
|
||||
}
|
||||
|
||||
function CreateChannelDialog({ open, onOpenChange, devices, onCreate }: CreateChannelDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState('');
|
||||
const [selectedDevices, setSelectedDevices] = useState<string[]>([]);
|
||||
|
||||
@@ -445,23 +446,21 @@ function CreateChannelDialog({ open, onOpenChange, devices, onCreate }: CreateCh
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Audio Channel</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new audio channel (bus) to route voices to specific output devices.
|
||||
</DialogDescription>
|
||||
<DialogTitle>{t('audioChannels.createDialog.title')}</DialogTitle>
|
||||
<DialogDescription>{t('audioChannels.createDialog.description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="channel-name">Channel Name</Label>
|
||||
<Label htmlFor="channel-name">{t('audioChannels.fields.name')}</Label>
|
||||
<Input
|
||||
id="channel-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., Virtual Cable, Broadcast"
|
||||
placeholder={t('audioChannels.fields.namePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Output Devices</Label>
|
||||
<Label>{t('audioChannels.labels.outputDevices')}</Label>
|
||||
<Select
|
||||
value={selectedDevices[0] || ''}
|
||||
onValueChange={(value) => {
|
||||
@@ -471,12 +470,12 @@ function CreateChannelDialog({ open, onOpenChange, devices, onCreate }: CreateCh
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select device" />
|
||||
<SelectValue placeholder={t('audioChannels.selectDevice')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{devices.map((device) => (
|
||||
<SelectItem key={device.id} value={device.id}>
|
||||
{device.name} {device.is_default && '(default)'}
|
||||
{device.name} {device.is_default && `(${t('audioChannels.defaultSuffix')})`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -509,10 +508,10 @@ function CreateChannelDialog({ open, onOpenChange, devices, onCreate }: CreateCh
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!name.trim()}>
|
||||
Create
|
||||
{t('audioChannels.createDialog.action')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -545,6 +544,7 @@ function EditChannelDialog({
|
||||
onUpdate,
|
||||
onSetVoices,
|
||||
}: EditChannelDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState(channel.name);
|
||||
const [selectedDevices, setSelectedDevices] = useState<string[]>(channel.device_ids);
|
||||
const [selectedVoices, setSelectedVoices] = useState<string[]>(channelVoices);
|
||||
@@ -560,16 +560,16 @@ function EditChannelDialog({
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Channel</DialogTitle>
|
||||
<DialogDescription>Update channel settings and voice assignments.</DialogDescription>
|
||||
<DialogTitle>{t('audioChannels.editDialog.title')}</DialogTitle>
|
||||
<DialogDescription>{t('audioChannels.editDialog.description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="edit-channel-name">Channel Name</Label>
|
||||
<Label htmlFor="edit-channel-name">{t('audioChannels.fields.name')}</Label>
|
||||
<Input id="edit-channel-name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Output Devices</Label>
|
||||
<Label>{t('audioChannels.labels.outputDevices')}</Label>
|
||||
<Select
|
||||
value=""
|
||||
onValueChange={(value) => {
|
||||
@@ -579,12 +579,12 @@ function EditChannelDialog({
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Add device" />
|
||||
<SelectValue placeholder={t('audioChannels.addDevice')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{devices.map((device) => (
|
||||
<SelectItem key={device.id} value={device.id}>
|
||||
{device.name} {device.is_default && '(default)'}
|
||||
{device.name} {device.is_default && `(${t('audioChannels.defaultSuffix')})`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -615,7 +615,7 @@ function EditChannelDialog({
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label>Assigned Voices</Label>
|
||||
<Label>{t('audioChannels.labels.assignedVoices')}</Label>
|
||||
<Select
|
||||
value=""
|
||||
onValueChange={(value) => {
|
||||
@@ -625,7 +625,7 @@ function EditChannelDialog({
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Add voice" />
|
||||
<SelectValue placeholder={t('audioChannels.addVoice')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{profiles.map((profile) => (
|
||||
@@ -663,10 +663,10 @@ function EditChannelDialog({
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!name.trim()}>
|
||||
Save
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { CSS } from '@dnd-kit/utilities';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ChevronDown, ChevronRight, GripVertical, Plus, Power, Trash2 } from 'lucide-react';
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
@@ -55,6 +56,7 @@ export function EffectsChainEditor({
|
||||
compact = false,
|
||||
showPresets = true,
|
||||
}: EffectsChainEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
|
||||
// Maintain stable IDs for each effect across renders.
|
||||
@@ -177,17 +179,27 @@ export function EffectsChainEditor({
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 flex-1 text-xs focus:ring-0 focus:ring-offset-0">
|
||||
<SelectValue placeholder="Load preset..." />
|
||||
<SelectValue placeholder={t('effects.chain.loadPreset')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{presets?.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
{p.description && (
|
||||
<span className="ml-1 text-muted-foreground">- {p.description}</span>
|
||||
)}
|
||||
</SelectItem>
|
||||
))}
|
||||
{presets?.map((p) => {
|
||||
const name = p.is_builtin
|
||||
? t(`effects.builtinPresets.${p.name}.name`, { defaultValue: p.name })
|
||||
: p.name;
|
||||
const description = p.is_builtin
|
||||
? t(`effects.builtinPresets.${p.name}.description`, {
|
||||
defaultValue: p.description ?? '',
|
||||
})
|
||||
: p.description;
|
||||
return (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{name}
|
||||
{description && (
|
||||
<span className="ml-1 text-muted-foreground">- {description}</span>
|
||||
)}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -198,7 +210,7 @@ export function EffectsChainEditor({
|
||||
className="h-8 px-2 text-xs text-muted-foreground"
|
||||
onClick={clearAll}
|
||||
>
|
||||
Clear
|
||||
{t('effects.chain.clear')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -229,12 +241,12 @@ export function EffectsChainEditor({
|
||||
<Select onValueChange={addEffect}>
|
||||
<SelectTrigger className="h-8 border-dashed text-xs text-muted-foreground focus:ring-0 focus:ring-offset-0">
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
<SelectValue placeholder="Add effect..." />
|
||||
<SelectValue placeholder={t('effects.chain.addEffect')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableEffects.effects.map((e) => (
|
||||
<SelectItem key={e.type} value={e.type}>
|
||||
{e.label}
|
||||
{t(`effects.types.${e.type}.label`, { defaultValue: e.label })}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -270,6 +282,7 @@ function SortableEffectItem({
|
||||
onToggleEnabled,
|
||||
onUpdateParam,
|
||||
}: SortableEffectItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id,
|
||||
});
|
||||
@@ -280,7 +293,9 @@ function SortableEffectItem({
|
||||
zIndex: isDragging ? 10 : undefined,
|
||||
};
|
||||
|
||||
const label = effectDef?.label ?? effect.type;
|
||||
const label = t(`effects.types.${effect.type}.label`, {
|
||||
defaultValue: effectDef?.label ?? effect.type,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -328,7 +343,7 @@ function SortableEffectItem({
|
||||
effect.enabled ? 'text-primary' : 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
onClick={onToggleEnabled}
|
||||
title={effect.enabled ? 'Disable' : 'Enable'}
|
||||
title={effect.enabled ? t('effects.chain.disable') : t('effects.chain.enable')}
|
||||
>
|
||||
<Power className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@@ -337,7 +352,7 @@ function SortableEffectItem({
|
||||
type="button"
|
||||
className="p-0.5 text-muted-foreground hover:text-destructive"
|
||||
onClick={onRemove}
|
||||
title="Remove"
|
||||
title={t('effects.chain.remove')}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@@ -352,7 +367,9 @@ function SortableEffectItem({
|
||||
<div key={paramName} className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-[11px] text-muted-foreground">
|
||||
{paramDef.description}
|
||||
{t(`effects.types.${effect.type}.params.${paramName}`, {
|
||||
defaultValue: paramDef.description,
|
||||
})}
|
||||
</Label>
|
||||
<span className="text-[11px] font-mono tabular-nums text-foreground">
|
||||
{currentValue.toFixed(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Loader2, Play, Save, Trash2, Wand2 } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor';
|
||||
import { GenerationPicker } from '@/components/Effects/GenerationPicker';
|
||||
@@ -25,6 +26,7 @@ import { useEffectsStore } from '@/stores/effectsStore';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
|
||||
export function EffectsDetail() {
|
||||
const { t } = useTranslation();
|
||||
const selectedPresetId = useEffectsStore((s) => s.selectedPresetId);
|
||||
const isCreatingNew = useEffectsStore((s) => s.isCreatingNew);
|
||||
const workingChain = useEffectsStore((s) => s.workingChain);
|
||||
@@ -95,6 +97,18 @@ export function EffectsDetail() {
|
||||
|
||||
const isEditing = !!selectedPresetId || isCreatingNew;
|
||||
const isBuiltIn = preset?.is_builtin ?? false;
|
||||
const presetName = preset
|
||||
? preset.is_builtin
|
||||
? t(`effects.builtinPresets.${preset.name}.name`, { defaultValue: preset.name })
|
||||
: preset.name
|
||||
: '';
|
||||
const presetDescription = preset
|
||||
? preset.is_builtin
|
||||
? t(`effects.builtinPresets.${preset.name}.description`, {
|
||||
defaultValue: preset.description ?? '',
|
||||
})
|
||||
: preset.description
|
||||
: '';
|
||||
|
||||
async function handlePreview() {
|
||||
if (!previewGenId || workingChain.length === 0) return;
|
||||
@@ -115,8 +129,8 @@ export function EffectsDetail() {
|
||||
setAudioWithAutoPlay(url, `preview-${Date.now()}`, null, 'Effects Preview');
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Preview failed',
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
title: t('effects.toast.previewFailed'),
|
||||
description: error instanceof Error ? error.message : t('common.unknownError'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
@@ -130,7 +144,7 @@ export function EffectsDetail() {
|
||||
|
||||
async function handleSaveNew() {
|
||||
if (!name.trim()) {
|
||||
toast({ title: 'Name required', variant: 'destructive' });
|
||||
toast({ title: t('effects.toast.nameRequired'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
@@ -143,11 +157,14 @@ export function EffectsDetail() {
|
||||
queryClient.invalidateQueries({ queryKey: ['effect-presets'] });
|
||||
setIsCreatingNew(false);
|
||||
setSelectedPresetId(created.id);
|
||||
toast({ title: 'Preset saved', description: `"${created.name}" has been created.` });
|
||||
toast({
|
||||
title: t('effects.toast.saved'),
|
||||
description: t('effects.toast.createdDescription', { name: created.name }),
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to save',
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
title: t('effects.toast.saveFailed'),
|
||||
description: error instanceof Error ? error.message : t('common.unknownError'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
@@ -166,11 +183,11 @@ export function EffectsDetail() {
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['effect-presets'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['effect-preset', selectedPresetId] });
|
||||
toast({ title: 'Preset updated' });
|
||||
toast({ title: t('effects.toast.updated') });
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to save',
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
title: t('effects.toast.saveFailed'),
|
||||
description: error instanceof Error ? error.message : t('common.unknownError'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
@@ -179,15 +196,15 @@ export function EffectsDetail() {
|
||||
}
|
||||
|
||||
function handleSaveAsNew() {
|
||||
// Open the dialog with a suggested name based on the current preset
|
||||
setSaveAsName(`${name} (Copy)`);
|
||||
const sourceName = isBuiltIn ? presetName : name;
|
||||
setSaveAsName(t('effects.saveAs.suggestedName', { name: sourceName }));
|
||||
setSaveAsDescription(description);
|
||||
setSaveAsDialogOpen(true);
|
||||
}
|
||||
|
||||
async function handleSaveAsConfirm() {
|
||||
if (!saveAsName.trim()) {
|
||||
toast({ title: 'Name required', variant: 'destructive' });
|
||||
toast({ title: t('effects.toast.nameRequired'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
@@ -200,11 +217,14 @@ export function EffectsDetail() {
|
||||
queryClient.invalidateQueries({ queryKey: ['effect-presets'] });
|
||||
setSaveAsDialogOpen(false);
|
||||
setSelectedPresetId(created.id);
|
||||
toast({ title: 'Preset saved', description: `"${created.name}" has been created.` });
|
||||
toast({
|
||||
title: t('effects.toast.saved'),
|
||||
description: t('effects.toast.createdDescription', { name: created.name }),
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to save',
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
title: t('effects.toast.saveFailed'),
|
||||
description: error instanceof Error ? error.message : t('common.unknownError'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
@@ -220,11 +240,11 @@ export function EffectsDetail() {
|
||||
queryClient.invalidateQueries({ queryKey: ['effect-presets'] });
|
||||
setSelectedPresetId(null);
|
||||
setWorkingChain([]);
|
||||
toast({ title: 'Preset deleted' });
|
||||
toast({ title: t('effects.toast.deleted') });
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to delete',
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
title: t('effects.toast.deleteFailed'),
|
||||
description: error instanceof Error ? error.message : t('common.unknownError'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
@@ -237,7 +257,7 @@ export function EffectsDetail() {
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center space-y-2">
|
||||
<Wand2 className="h-10 w-10 mx-auto opacity-30" />
|
||||
<p className="text-sm">Select a preset or create a new one</p>
|
||||
<p className="text-sm">{t('effects.placeholder')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -245,10 +265,13 @@ export function EffectsDetail() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{isCreatingNew ? 'New Preset' : isBuiltIn ? preset?.name : 'Edit Preset'}
|
||||
{isCreatingNew
|
||||
? t('effects.detail.newTitle')
|
||||
: isBuiltIn
|
||||
? presetName
|
||||
: t('effects.detail.editTitle')}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
{!isBuiltIn && !isCreatingNew && (
|
||||
@@ -261,7 +284,7 @@ export function EffectsDetail() {
|
||||
disabled={deleting}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{deleting ? 'Deleting...' : 'Delete'}
|
||||
{deleting ? t('effects.detail.deleting') : t('common.delete')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -270,7 +293,7 @@ export function EffectsDetail() {
|
||||
disabled={saving || workingChain.length === 0}
|
||||
>
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
{saving ? t('effects.detail.saving') : t('common.save')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
@@ -282,7 +305,7 @@ export function EffectsDetail() {
|
||||
disabled={saving || workingChain.length === 0}
|
||||
>
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
{saving ? 'Saving...' : 'Save Preset'}
|
||||
{saving ? t('effects.detail.saving') : t('effects.detail.savePreset')}
|
||||
</Button>
|
||||
)}
|
||||
{isBuiltIn && (
|
||||
@@ -294,51 +317,46 @@ export function EffectsDetail() {
|
||||
disabled={saving}
|
||||
>
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
{saving ? 'Saving...' : 'Save as Custom'}
|
||||
{saving ? t('effects.detail.saving') : t('effects.detail.saveAsCustom')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto space-y-5 pr-1">
|
||||
{/* Name & description */}
|
||||
{(isCreatingNew || !isBuiltIn) && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Name</Label>
|
||||
<Label className="text-xs">{t('effects.fields.name')}</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="My preset..."
|
||||
placeholder={t('effects.fields.namePlaceholder')}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Description</Label>
|
||||
<Label className="text-xs">{t('effects.fields.description')}</Label>
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Describe what this preset does..."
|
||||
placeholder={t('effects.fields.descriptionPlaceholder')}
|
||||
className="min-h-[60px] resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Built-in description (read-only) */}
|
||||
{isBuiltIn && preset?.description && (
|
||||
<p className="text-sm text-muted-foreground">{preset.description}</p>
|
||||
{isBuiltIn && presetDescription && (
|
||||
<p className="text-sm text-muted-foreground">{presetDescription}</p>
|
||||
)}
|
||||
|
||||
{/* Effects chain editor */}
|
||||
<EffectsChainEditor value={workingChain} onChange={setWorkingChain} showPresets={false} />
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Preview section */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs">Preview</Label>
|
||||
<Label className="text-xs">{t('effects.preview.label')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<GenerationPicker
|
||||
selectedId={previewGenId}
|
||||
@@ -355,38 +373,33 @@ export function EffectsDetail() {
|
||||
{previewLoading ? (
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Processing...
|
||||
{t('effects.preview.processing')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
Preview
|
||||
{t('effects.preview.button')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Preview applies effects to the clean version without saving.
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground">{t('effects.preview.hint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save as Custom dialog */}
|
||||
<Dialog open={saveAsDialogOpen} onOpenChange={setSaveAsDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Save as Custom Preset</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new custom preset based on the current effects chain.
|
||||
</DialogDescription>
|
||||
<DialogTitle>{t('effects.saveAs.title')}</DialogTitle>
|
||||
<DialogDescription>{t('effects.saveAs.description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 py-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Name</Label>
|
||||
<Label className="text-xs">{t('effects.fields.name')}</Label>
|
||||
<Input
|
||||
value={saveAsName}
|
||||
onChange={(e) => setSaveAsName(e.target.value)}
|
||||
placeholder="My preset..."
|
||||
placeholder={t('effects.fields.namePlaceholder')}
|
||||
className="h-9"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
@@ -397,22 +410,22 @@ export function EffectsDetail() {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Description</Label>
|
||||
<Label className="text-xs">{t('effects.fields.description')}</Label>
|
||||
<Textarea
|
||||
value={saveAsDescription}
|
||||
onChange={(e) => setSaveAsDescription(e.target.value)}
|
||||
placeholder="Describe what this preset does..."
|
||||
placeholder={t('effects.fields.descriptionPlaceholder')}
|
||||
className="min-h-[60px] resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setSaveAsDialogOpen(false)} disabled={saving}>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSaveAsConfirm} disabled={saving || !saveAsName.trim()}>
|
||||
<Save className="h-3.5 w-3.5 mr-1.5" />
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
{saving ? t('effects.detail.saving') : t('common.save')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Loader2, Plus, Sparkles, Wand2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import type { EffectPresetResponse } from '@/lib/api/types';
|
||||
@@ -7,6 +8,7 @@ import { cn } from '@/lib/utils/cn';
|
||||
import { useEffectsStore } from '@/stores/effectsStore';
|
||||
|
||||
export function EffectsList() {
|
||||
const { t } = useTranslation();
|
||||
const selectedPresetId = useEffectsStore((s) => s.selectedPresetId);
|
||||
const setSelectedPresetId = useEffectsStore((s) => s.setSelectedPresetId);
|
||||
const setWorkingChain = useEffectsStore((s) => s.setWorkingChain);
|
||||
@@ -44,10 +46,10 @@ export function EffectsList() {
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">Effects</h2>
|
||||
<h2 className="text-lg font-semibold">{t('effects.title')}</h2>
|
||||
<Button variant="outline" size="sm" className="h-8 gap-1.5" onClick={handleCreateNew}>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
New Preset
|
||||
{t('effects.newPreset')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -57,7 +59,7 @@ export function EffectsList() {
|
||||
{builtIn.length > 0 && (
|
||||
<div>
|
||||
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
|
||||
Built-in
|
||||
{t('effects.sections.builtin')}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{builtIn.map((preset) => (
|
||||
@@ -76,7 +78,7 @@ export function EffectsList() {
|
||||
{userPresets.length > 0 && (
|
||||
<div>
|
||||
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
|
||||
Custom
|
||||
{t('effects.sections.custom')}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{userPresets.map((preset) => (
|
||||
@@ -95,16 +97,14 @@ export function EffectsList() {
|
||||
{isCreatingNew && (
|
||||
<div>
|
||||
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
|
||||
New
|
||||
{t('effects.sections.new')}
|
||||
</div>
|
||||
<div className="rounded-xl border-2 border-accent/40 bg-accent/5 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-accent" />
|
||||
<span className="text-sm font-medium">Unsaved Preset</span>
|
||||
<span className="text-sm font-medium">{t('effects.unsaved.title')}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Configure effects in the panel on the right.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t('effects.unsaved.hint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -122,7 +122,16 @@ function PresetCard({
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const effectCount = preset.effects_chain.length;
|
||||
const name = preset.is_builtin
|
||||
? t(`effects.builtinPresets.${preset.name}.name`, { defaultValue: preset.name })
|
||||
: preset.name;
|
||||
const description = preset.is_builtin
|
||||
? t(`effects.builtinPresets.${preset.name}.description`, {
|
||||
defaultValue: preset.description ?? '',
|
||||
})
|
||||
: preset.description;
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -139,19 +148,19 @@ function PresetCard({
|
||||
<Wand2
|
||||
className={cn('h-4 w-4 shrink-0', isSelected ? 'text-accent' : 'text-muted-foreground')}
|
||||
/>
|
||||
<span className="text-sm font-medium truncate">{preset.name}</span>
|
||||
<span className="text-sm font-medium truncate">{name}</span>
|
||||
{preset.is_builtin && (
|
||||
<span className="text-[10px] bg-muted text-muted-foreground px-1.5 py-0.5 rounded-full shrink-0">
|
||||
built-in
|
||||
{t('effects.badge.builtin')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-1 pl-6">
|
||||
{preset.description || 'No description'}
|
||||
{description || t('effects.noDescription')}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-1.5 pl-6">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{effectCount} effect{effectCount !== 1 ? 's' : ''}
|
||||
{t('effects.effectCount', { count: effectCount })}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground/50">
|
||||
{preset.effects_chain
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useMatchRoute } from '@tanstack/react-router';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { Loader2, SlidersHorizontal, Sparkles } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form';
|
||||
import {
|
||||
@@ -34,6 +35,7 @@ export function FloatingGenerateBox({
|
||||
isPlayerOpen = false,
|
||||
showVoiceSelector = false,
|
||||
}: FloatingGenerateBoxProps) {
|
||||
const { t } = useTranslation();
|
||||
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
|
||||
const setSelectedProfileId = useUIStore((state) => state.setSelectedProfileId);
|
||||
const setSelectedEngine = useUIStore((state) => state.setSelectedEngine);
|
||||
@@ -276,10 +278,12 @@ export function FloatingGenerateBox({
|
||||
onChange={field.onChange}
|
||||
placeholder={
|
||||
isStoriesRoute && currentStory
|
||||
? `Generate speech for "${currentStory.name}"... (type / for effects)`
|
||||
? t('generation.placeholder.storyWithEffects', {
|
||||
name: currentStory.name,
|
||||
})
|
||||
: selectedProfile
|
||||
? `Type / for effects like [laugh], [sigh]...`
|
||||
: 'Select a voice profile above...'
|
||||
? t('generation.placeholder.effectsHint')
|
||||
: t('generation.placeholder.selectVoice')
|
||||
}
|
||||
className="px-3 py-2 resize-none bg-transparent border-none focus-visible:ring-0 focus-visible:ring-offset-0 focus:outline-none focus:ring-0 outline-none ring-0 rounded-2xl text-sm w-full"
|
||||
style={{
|
||||
@@ -302,10 +306,12 @@ export function FloatingGenerateBox({
|
||||
}}
|
||||
placeholder={
|
||||
isStoriesRoute && currentStory
|
||||
? `Generate speech for "${currentStory.name}"...`
|
||||
? t('generation.placeholder.story', { name: currentStory.name })
|
||||
: selectedProfile
|
||||
? `Generate speech using ${selectedProfile.name}...`
|
||||
: 'Select a voice profile above...'
|
||||
? t('generation.placeholder.profile', {
|
||||
name: selectedProfile.name,
|
||||
})
|
||||
: t('generation.placeholder.selectVoice')
|
||||
}
|
||||
className="resize-none bg-transparent border-none focus-visible:ring-0 focus-visible:ring-offset-0 focus:outline-none focus:ring-0 outline-none ring-0 rounded-2xl text-sm placeholder:text-muted-foreground/60 w-full"
|
||||
style={{
|
||||
@@ -334,10 +340,10 @@ export function FloatingGenerateBox({
|
||||
size="icon"
|
||||
aria-label={
|
||||
isPending
|
||||
? 'Generating...'
|
||||
? t('generation.button.generating')
|
||||
: !selectedProfileId
|
||||
? 'Select a voice profile first'
|
||||
: 'Generate speech'
|
||||
? t('generation.button.selectFirst')
|
||||
: t('generation.button.generate')
|
||||
}
|
||||
>
|
||||
{isPending ? (
|
||||
@@ -348,10 +354,10 @@ export function FloatingGenerateBox({
|
||||
</Button>
|
||||
<span className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 whitespace-nowrap rounded-md bg-popover px-3 py-1.5 text-xs text-popover-foreground border border-border opacity-0 transition-opacity group-hover:opacity-100 z-[9999]">
|
||||
{isPending
|
||||
? 'Generating...'
|
||||
? t('generation.button.generating')
|
||||
: !selectedProfileId
|
||||
? 'Select a voice profile first'
|
||||
: 'Generate speech'}
|
||||
? t('generation.button.selectFirst')
|
||||
: t('generation.button.generate')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -379,15 +385,15 @@ export function FloatingGenerateBox({
|
||||
)}
|
||||
aria-label={
|
||||
isInstructExpanded
|
||||
? 'Hide delivery instructions'
|
||||
: 'Show delivery instructions'
|
||||
? t('generation.instruct.hide')
|
||||
: t('generation.instruct.show')
|
||||
}
|
||||
aria-pressed={isInstructExpanded}
|
||||
>
|
||||
<SlidersHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 whitespace-nowrap rounded-md bg-popover px-3 py-1.5 text-xs text-popover-foreground border border-border opacity-0 transition-opacity group-hover:opacity-100 z-[9999]">
|
||||
Delivery instructions (tone, emotion, pace)
|
||||
{t('generation.instruct.tooltip')}
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -414,7 +420,7 @@ export function FloatingGenerateBox({
|
||||
<FormControl>
|
||||
<Textarea
|
||||
{...field}
|
||||
placeholder="Delivery instructions — e.g. Speak slowly with warmth, Authoritative and clear..."
|
||||
placeholder={t('generation.instruct.placeholder')}
|
||||
className="resize-none bg-transparent border border-accent/20 focus-visible:ring-1 focus-visible:ring-accent/40 rounded-2xl text-sm placeholder:text-muted-foreground/60 w-full px-3 py-2"
|
||||
style={{ minHeight: '60px', maxHeight: '160px' }}
|
||||
maxLength={500}
|
||||
@@ -444,7 +450,7 @@ export function FloatingGenerateBox({
|
||||
onValueChange={(value) => setSelectedProfileId(value || null)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all w-full">
|
||||
<SelectValue placeholder="Select a voice..." />
|
||||
<SelectValue placeholder={t('generation.voiceSelector.placeholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{profiles?.map((profile) => (
|
||||
@@ -498,16 +504,16 @@ export function FloatingGenerateBox({
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all">
|
||||
<SelectValue placeholder="No effects" />
|
||||
<SelectValue placeholder={t('generation.effects.none')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none" className="text-xs">
|
||||
No effects
|
||||
{t('generation.effects.none')}
|
||||
</SelectItem>
|
||||
{selectedProfile?.effects_chain &&
|
||||
selectedProfile.effects_chain.length > 0 && (
|
||||
<SelectItem value="_profile" className="text-xs">
|
||||
Profile default
|
||||
{t('generation.effects.profileDefault')}
|
||||
</SelectItem>
|
||||
)}
|
||||
{effectPresets?.map((preset) => (
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Wand2,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -88,6 +89,7 @@ function AudioBars({ mode }: { mode: 'idle' | 'generating' | 'playing' }) {
|
||||
|
||||
// NEW ALTERNATE HISTORY VIEW - FIXED HEIGHT ROWS WITH INFINITE SCROLL
|
||||
export function HistoryTable() {
|
||||
const { t } = useTranslation();
|
||||
const [page, setPage] = useState(0);
|
||||
const [allHistory, setAllHistory] = useState<HistoryResponse[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
@@ -671,46 +673,47 @@ export function HistoryTable() {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground/50 hover:bg-muted-foreground/20 hover:text-muted-foreground"
|
||||
aria-label="Actions"
|
||||
aria-label={t('history.actions.menu')}
|
||||
disabled={isGenerating}
|
||||
>
|
||||
<MoreHorizontal className="h-2 w-2" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handlePlay(gen.id, gen.text, gen.profile_id)}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handlePlay(gen.id, gen.text, gen.profile_id)}
|
||||
>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Play
|
||||
{t('history.actions.play')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDownloadAudio(gen.id, gen.text)}
|
||||
disabled={exportGenerationAudio.isPending}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export Audio
|
||||
{t('history.actions.exportAudio')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleExportPackage(gen.id, gen.text)}
|
||||
disabled={exportGeneration.isPending}
|
||||
>
|
||||
<FileArchive className="mr-2 h-4 w-4" />
|
||||
Export Package
|
||||
{t('history.actions.exportPackage')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleApplyEffects(gen.id)}>
|
||||
<Wand2 className="mr-2 h-4 w-4" />
|
||||
Apply Effects
|
||||
{t('history.actions.applyEffects')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleRegenerate(gen.id)}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
Regenerate
|
||||
{t('history.actions.regenerate')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDeleteClick(gen.id, gen.profile_name)}
|
||||
disabled={deleteGeneration.isPending}
|
||||
// className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
{t('common.delete')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -802,10 +805,9 @@ export function HistoryTable() {
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Generation</DialogTitle>
|
||||
<DialogTitle>{t('history.deleteDialog.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete this generation from "{generationToDelete?.name}"?
|
||||
This action cannot be undone.
|
||||
{t('history.deleteDialog.body', { name: generationToDelete?.name })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
@@ -816,14 +818,14 @@ export function HistoryTable() {
|
||||
setGenerationToDelete(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteConfirm}
|
||||
disabled={deleteGeneration.isPending}
|
||||
>
|
||||
{deleteGeneration.isPending ? 'Deleting...' : 'Delete'}
|
||||
{deleteGeneration.isPending ? t('history.deleteDialog.deleting') : t('common.delete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -832,23 +834,23 @@ export function HistoryTable() {
|
||||
<Dialog open={clearFailedDialogOpen} onOpenChange={setClearFailedDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Clear failed generations</DialogTitle>
|
||||
<DialogTitle>{t('history.clearFailedDialog.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will permanently delete {failedCount} failed{' '}
|
||||
{failedCount === 1 ? 'generation' : 'generations'} from your history. This cannot be
|
||||
undone.
|
||||
{t('history.clearFailedDialog.body', { count: failedCount })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setClearFailedDialogOpen(false)}>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleClearFailedConfirm}
|
||||
disabled={clearFailed.isPending}
|
||||
>
|
||||
{clearFailed.isPending ? 'Clearing...' : 'Clear all'}
|
||||
{clearFailed.isPending
|
||||
? t('history.clearFailedDialog.clearing')
|
||||
: t('history.clearFailedDialog.clearAll')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -857,9 +859,9 @@ export function HistoryTable() {
|
||||
<Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Import Generation</DialogTitle>
|
||||
<DialogTitle>{t('history.importDialog.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Import the generation from "{selectedFile?.name}". This will add it to your history.
|
||||
{t('history.importDialog.body', { name: selectedFile?.name })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
@@ -873,13 +875,15 @@ export function HistoryTable() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleImportConfirm}
|
||||
disabled={importGeneration.isPending || !selectedFile}
|
||||
>
|
||||
{importGeneration.isPending ? 'Importing...' : 'Import'}
|
||||
{importGeneration.isPending
|
||||
? t('history.importDialog.importing')
|
||||
: t('history.importDialog.action')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -888,21 +892,20 @@ export function HistoryTable() {
|
||||
<Dialog open={effectsDialogOpen} onOpenChange={setEffectsDialogOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Apply Effects</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure post-processing effects to apply to this generation. A new version will be
|
||||
created.
|
||||
</DialogDescription>
|
||||
<DialogTitle>{t('history.effectsDialog.title')}</DialogTitle>
|
||||
<DialogDescription>{t('history.effectsDialog.body')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{effectsTargetVersions.length > 1 && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Source</label>
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t('history.effectsDialog.sourceLabel')}
|
||||
</label>
|
||||
<Select
|
||||
value={effectsSourceVersionId ?? ''}
|
||||
onValueChange={(val) => setEffectsSourceVersionId(val || null)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="Select source version" />
|
||||
<SelectValue placeholder={t('history.effectsDialog.sourcePlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{effectsTargetVersions.map((v) => (
|
||||
@@ -924,13 +927,15 @@ export function HistoryTable() {
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEffectsDialogOpen(false)}>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleApplyEffectsConfirm}
|
||||
disabled={applyingEffects || effectsChain.length === 0}
|
||||
>
|
||||
{applyingEffects ? 'Applying...' : 'Apply'}
|
||||
{applyingEffects
|
||||
? t('history.effectsDialog.applying')
|
||||
: t('history.effectsDialog.apply')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Sparkles, Upload } from 'lucide-react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FloatingGenerateBox } from '@/components/Generation/FloatingGenerateBox';
|
||||
import { HistoryTable } from '@/components/History/HistoryTable';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -20,6 +21,7 @@ import { usePlayerStore } from '@/stores/playerStore';
|
||||
import { useUIStore } from '@/stores/uiStore';
|
||||
|
||||
export function MainEditor() {
|
||||
const { t } = useTranslation();
|
||||
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
||||
const isPlayerVisible = !!audioUrl;
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
@@ -39,8 +41,8 @@ export function MainEditor() {
|
||||
if (file) {
|
||||
if (!file.name.endsWith('.voicebox.zip')) {
|
||||
toast({
|
||||
title: 'Invalid file type',
|
||||
description: 'Please select a valid .voicebox.zip file',
|
||||
title: t('main.import.invalidTitle'),
|
||||
description: t('main.import.invalidDescription'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
@@ -60,13 +62,13 @@ export function MainEditor() {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
toast({
|
||||
title: 'Profile imported',
|
||||
description: 'Voice profile imported successfully',
|
||||
title: t('main.import.successTitle'),
|
||||
description: t('main.import.successDescription'),
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: 'Failed to import profile',
|
||||
title: t('main.import.failedTitle'),
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
@@ -76,21 +78,17 @@ export function MainEditor() {
|
||||
};
|
||||
|
||||
return (
|
||||
// Main view: Profiles top left, Generator bottom left, History right
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 lg:gap-6 h-full min-h-0 overflow-hidden relative">
|
||||
{/* Left Column */}
|
||||
<div className="flex flex-col min-h-0 overflow-hidden relative lg:overflow-hidden">
|
||||
{/* Scroll Mask - Always visible, behind content */}
|
||||
<div className="absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-background to-transparent z-0 pointer-events-none" />
|
||||
|
||||
{/* Fixed Header */}
|
||||
<div className="absolute top-0 left-0 right-0 z-10">
|
||||
<div className="flex items-center justify-between mb-4 px-1">
|
||||
<h2 className="text-2xl font-bold">Voicebox</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleImportClick}>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
Import Voice
|
||||
{t('main.importVoice')}
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
@@ -101,13 +99,12 @@ export function MainEditor() {
|
||||
/>
|
||||
<Button onClick={() => setDialogOpen(true)}>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
Create Voice
|
||||
{t('main.createVoice')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={cn('flex-1 min-h-0 overflow-y-auto pt-14 pb-4', isPlayerVisible && 'lg:pb-32')}
|
||||
@@ -120,25 +117,18 @@ export function MainEditor() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider - single column only */}
|
||||
{/* <div className="border-t border-border -my-3 lg:hidden" /> */}
|
||||
|
||||
{/* Right Column - History */}
|
||||
<div className="flex flex-col min-h-0 overflow-hidden">
|
||||
<HistoryTable />
|
||||
</div>
|
||||
|
||||
{/* Floating Generate Box */}
|
||||
<FloatingGenerateBox isPlayerOpen={!!audioUrl} />
|
||||
|
||||
{/* Import Dialog */}
|
||||
<Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Import Profile</DialogTitle>
|
||||
<DialogTitle>{t('main.import.dialogTitle')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Import the profile from "{selectedFile?.name}". This will create a new profile with
|
||||
all samples.
|
||||
{t('main.import.dialogDescription', { name: selectedFile?.name })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
@@ -152,13 +142,13 @@ export function MainEditor() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleImportConfirm}
|
||||
disabled={importProfile.isPending || !selectedFile}
|
||||
>
|
||||
{importProfile.isPending ? 'Importing...' : 'Import'}
|
||||
{importProfile.isPending ? t('main.import.importing') : t('main.import.action')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -119,6 +120,7 @@ function formatBytes(bytes: number): string {
|
||||
}
|
||||
|
||||
export function ModelManagement() {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const platform = usePlatform();
|
||||
@@ -270,8 +272,8 @@ export function ModelManagement() {
|
||||
setDownloadingModel(null);
|
||||
setDownloadingDisplayName(null);
|
||||
toast({
|
||||
title: 'Download failed',
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
title: t('models.toast.downloadFailed'),
|
||||
description: error instanceof Error ? error.message : t('common.unknownError'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
@@ -309,8 +311,8 @@ export function ModelManagement() {
|
||||
setDownloadingModel(prevDownloadingModel);
|
||||
setDownloadingDisplayName(prevDownloadingDisplayName);
|
||||
toast({
|
||||
title: 'Cancel failed',
|
||||
description: 'Could not cancel the download task.',
|
||||
title: t('models.toast.cancelFailed'),
|
||||
description: t('models.toast.cancelFailedDescription'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
@@ -336,8 +338,10 @@ export function ModelManagement() {
|
||||
},
|
||||
onSuccess: async () => {
|
||||
toast({
|
||||
title: 'Model deleted',
|
||||
description: `${modelToDelete?.displayName || 'Model'} has been deleted successfully.`,
|
||||
title: t('models.toast.deleted'),
|
||||
description: t('models.toast.deletedDescription', {
|
||||
name: modelToDelete?.displayName || t('models.defaultName'),
|
||||
}),
|
||||
});
|
||||
setDeleteDialogOpen(false);
|
||||
setModelToDelete(null);
|
||||
@@ -348,7 +352,7 @@ export function ModelManagement() {
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: 'Delete failed',
|
||||
title: t('models.toast.deleteFailed'),
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
@@ -361,15 +365,15 @@ export function ModelManagement() {
|
||||
},
|
||||
onSuccess: async (_data, modelName) => {
|
||||
toast({
|
||||
title: 'Model unloaded',
|
||||
description: `${modelName} has been unloaded from memory.`,
|
||||
title: t('models.toast.unloaded'),
|
||||
description: t('models.toast.unloadedDescription', { name: modelName }),
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: ['modelStatus'], refetchType: 'all' });
|
||||
await queryClient.refetchQueries({ queryKey: ['modelStatus'] });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: 'Unload failed',
|
||||
title: t('models.toast.unloadFailed'),
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
@@ -377,7 +381,7 @@ export function ModelManagement() {
|
||||
});
|
||||
|
||||
const formatSize = (sizeMb?: number): string => {
|
||||
if (!sizeMb) return 'Unknown size';
|
||||
if (!sizeMb) return t('models.unknownSize');
|
||||
if (sizeMb < 1024) return `${sizeMb.toFixed(1)} MB`;
|
||||
return `${(sizeMb / 1024).toFixed(2)} GB`;
|
||||
};
|
||||
@@ -410,8 +414,8 @@ export function ModelManagement() {
|
||||
|
||||
// Build sections
|
||||
const sections: { label: string; models: ModelStatus[] }[] = [
|
||||
{ label: 'Voice Generation', models: voiceModels },
|
||||
{ label: 'Transcription', models: whisperModels },
|
||||
{ label: t('models.sections.voiceGeneration'), models: voiceModels },
|
||||
{ label: t('models.sections.transcription'), models: whisperModels },
|
||||
];
|
||||
|
||||
// Get detail modal state for selected model
|
||||
@@ -427,16 +431,14 @@ export function ModelManagement() {
|
||||
// Derive license from HF data
|
||||
const license =
|
||||
hfModelInfo?.cardData?.license ||
|
||||
hfModelInfo?.tags?.find((t) => t.startsWith('license:'))?.replace('license:', '');
|
||||
hfModelInfo?.tags?.find((tag) => tag.startsWith('license:'))?.replace('license:', '');
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="shrink-0 pb-4">
|
||||
<h1 className="text-lg font-semibold">Models</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Download and manage AI models for voice generation and transcription
|
||||
</p>
|
||||
<h1 className="text-lg font-semibold">{t('models.title')}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t('models.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{/* Model storage location */}
|
||||
@@ -444,7 +446,7 @@ export function ModelManagement() {
|
||||
<div className="shrink-0 pb-4 border-b mb-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<span className="text-xs text-muted-foreground">Storage location</span>
|
||||
<span className="text-xs text-muted-foreground">{t('models.storage.location')}</span>
|
||||
<p
|
||||
className="text-xs font-mono text-muted-foreground/70 truncate"
|
||||
title={cacheDir.path}
|
||||
@@ -461,12 +463,12 @@ export function ModelManagement() {
|
||||
try {
|
||||
await platform.filesystem.openPath(cacheDir.path);
|
||||
} catch {
|
||||
toast({ title: 'Failed to open model folder', variant: 'destructive' });
|
||||
toast({ title: t('models.toast.openFolderFailed'), variant: 'destructive' });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FolderOpen className="h-3 w-3" />
|
||||
Open
|
||||
{t('models.storage.open')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -475,12 +477,12 @@ export function ModelManagement() {
|
||||
onClick={async () => {
|
||||
try {
|
||||
const newDir = await platform.filesystem.pickDirectory(
|
||||
'Choose model storage folder',
|
||||
t('models.storage.pickerTitle'),
|
||||
);
|
||||
if (!newDir) return;
|
||||
setPendingMigrateDir(newDir);
|
||||
} catch {
|
||||
toast({ title: 'Failed to open folder picker', variant: 'destructive' });
|
||||
toast({ title: t('models.toast.pickerFailed'), variant: 'destructive' });
|
||||
}
|
||||
}}
|
||||
disabled={migrating}
|
||||
@@ -490,7 +492,7 @@ export function ModelManagement() {
|
||||
) : (
|
||||
<FolderOpen className="h-3 w-3" />
|
||||
)}
|
||||
{migrating ? 'Migrating...' : 'Change'}
|
||||
{migrating ? t('models.storage.migrating') : t('models.storage.change')}
|
||||
</Button>
|
||||
{customModelsDir && (
|
||||
<Button
|
||||
@@ -500,13 +502,13 @@ export function ModelManagement() {
|
||||
disabled={migrating}
|
||||
onClick={async () => {
|
||||
setCustomModelsDir(null);
|
||||
toast({ title: 'Reset to default location. Restarting server...' });
|
||||
toast({ title: t('models.toast.resetToDefault') });
|
||||
await platform.lifecycle.restartServer('');
|
||||
queryClient.invalidateQueries();
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
Reset
|
||||
{t('models.storage.reset')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -520,7 +522,7 @@ export function ModelManagement() {
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : modelStatus ? (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto space-y-6">
|
||||
<div className="flex-1 min-h-0 overflow-y-auto space-y-6 pb-6">
|
||||
{sections.map((section) => (
|
||||
<div key={section.label}>
|
||||
<h2 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1 px-1">
|
||||
@@ -565,7 +567,7 @@ export function ModelManagement() {
|
||||
<div className="text-[10px] text-muted-foreground truncate">
|
||||
{hasProgress
|
||||
? `${formatBytes(dl.current ?? 0)} / ${formatBytes(dl.total!)} (${pct.toFixed(0)}%)`
|
||||
: dl?.filename || 'Connecting...'}
|
||||
: dl?.filename || t('models.progress.connecting')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -576,12 +578,12 @@ export function ModelManagement() {
|
||||
<div className="shrink-0 flex items-center gap-2">
|
||||
{hasError && (
|
||||
<Badge variant="destructive" className="text-[10px] h-5">
|
||||
Error
|
||||
{t('common.error')}
|
||||
</Badge>
|
||||
)}
|
||||
{model.loaded && (
|
||||
<Badge className="text-[10px] h-5 bg-accent/15 text-accent border-accent/30 hover:bg-accent/15">
|
||||
Loaded
|
||||
{t('models.status.loaded')}
|
||||
</Badge>
|
||||
)}
|
||||
{model.downloaded && !isDownloading && !hasError && (
|
||||
@@ -613,7 +615,7 @@ export function ModelManagement() {
|
||||
) : (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span>Problems</span>
|
||||
<span>{t('models.problems.title')}</span>
|
||||
<Badge variant="destructive" className="text-[10px] h-4 px-1.5 rounded-full">
|
||||
{errorCount}
|
||||
</Badge>
|
||||
@@ -626,7 +628,7 @@ export function ModelManagement() {
|
||||
disabled={clearAllMutation.isPending}
|
||||
>
|
||||
<RotateCcw className="h-3 w-3 mr-1" />
|
||||
Clear All
|
||||
{t('models.problems.clearAll')}
|
||||
</Button>
|
||||
</div>
|
||||
{consoleOpen && (
|
||||
@@ -645,13 +647,13 @@ export function ModelManagement() {
|
||||
) : (
|
||||
<>
|
||||
{': '}
|
||||
<span className="text-[#808080]">
|
||||
No error details available. Try downloading again.
|
||||
</span>
|
||||
<span className="text-[#808080]">{t('models.problems.noDetails')}</span>
|
||||
</>
|
||||
)}
|
||||
<div className="text-[#6a9955] mt-0.5">
|
||||
started at {new Date(dl.started_at).toLocaleString()}
|
||||
{t('models.problems.startedAt', {
|
||||
time: new Date(dl.started_at).toLocaleString(),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -692,13 +694,13 @@ export function ModelManagement() {
|
||||
{freshSelectedModel.loaded && (
|
||||
<Badge className="text-xs bg-accent/15 text-accent border-accent/30 hover:bg-accent/15">
|
||||
<CircleCheck className="h-3 w-3 mr-1" />
|
||||
Loaded
|
||||
{t('models.status.loaded')}
|
||||
</Badge>
|
||||
)}
|
||||
{selectedState?.hasError && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
<CircleX className="h-3 w-3 mr-1" />
|
||||
Error
|
||||
{t('common.error')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -707,7 +709,7 @@ export function ModelManagement() {
|
||||
{hfLoading && freshSelectedModel.hf_repo_id && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground py-2">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Loading model info...
|
||||
{t('models.detail.loadingInfo')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -734,23 +736,29 @@ export function ModelManagement() {
|
||||
)}
|
||||
{hfModelInfo.author && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
by {hfModelInfo.author}
|
||||
{t('models.detail.byAuthor', { author: hfModelInfo.author })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1" title="Downloads">
|
||||
<span
|
||||
className="flex items-center gap-1"
|
||||
title={t('models.detail.downloads')}
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
{formatDownloads(hfModelInfo.downloads)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1" title="Likes">
|
||||
<span className="flex items-center gap-1" title={t('models.detail.likes')}>
|
||||
<Heart className="h-3.5 w-3.5" />
|
||||
{formatDownloads(hfModelInfo.likes)}
|
||||
</span>
|
||||
{license && (
|
||||
<span className="flex items-center gap-1" title="License">
|
||||
<span
|
||||
className="flex items-center gap-1"
|
||||
title={t('models.detail.license')}
|
||||
>
|
||||
<Scale className="h-3.5 w-3.5" />
|
||||
{formatLicense(license)}
|
||||
</span>
|
||||
@@ -762,8 +770,12 @@ export function ModelManagement() {
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{hfModelInfo.cardData.language.length > 10
|
||||
? `${hfModelInfo.cardData.language.length} languages supported`
|
||||
: `Languages: ${hfModelInfo.cardData.language.join(', ')}`}
|
||||
? t('models.detail.languagesCount', {
|
||||
count: hfModelInfo.cardData.language.length,
|
||||
})
|
||||
: t('models.detail.languagesList', {
|
||||
list: hfModelInfo.cardData.language.join(', '),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -774,7 +786,9 @@ export function ModelManagement() {
|
||||
{freshSelectedModel.downloaded && freshSelectedModel.size_mb && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<HardDrive className="h-3.5 w-3.5" />
|
||||
<span>{formatSize(freshSelectedModel.size_mb)} on disk</span>
|
||||
<span>
|
||||
{t('models.detail.onDisk', { size: formatSize(freshSelectedModel.size_mb) })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -796,7 +810,7 @@ export function ModelManagement() {
|
||||
className="flex-1"
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Retry Download
|
||||
{t('models.actions.retry')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -825,7 +839,7 @@ export function ModelManagement() {
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{hasProgress
|
||||
? `${formatBytes(dl.current ?? 0)} / ${formatBytes(dl.total!)} (${pct.toFixed(1)}%)`
|
||||
: dl?.filename || 'Connecting to HuggingFace...'}
|
||||
: dl?.filename || t('models.progress.connectingHf')}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@@ -858,7 +872,9 @@ export function ModelManagement() {
|
||||
) : (
|
||||
<Unplug className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{unloadMutation.isPending ? 'Unloading...' : 'Unload'}
|
||||
{unloadMutation.isPending
|
||||
? t('models.actions.unloading')
|
||||
: t('models.actions.unload')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
@@ -875,13 +891,13 @@ export function ModelManagement() {
|
||||
disabled={freshSelectedModel.loaded}
|
||||
title={
|
||||
freshSelectedModel.loaded
|
||||
? 'Unload model before deleting'
|
||||
: 'Delete model'
|
||||
? t('models.actions.unloadFirst')
|
||||
: t('models.actions.deleteModel')
|
||||
}
|
||||
className="flex-1"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete Model
|
||||
{t('models.actions.deleteModel')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -891,7 +907,7 @@ export function ModelManagement() {
|
||||
className="flex-1"
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download
|
||||
{t('models.actions.download')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -905,20 +921,23 @@ export function ModelManagement() {
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Model</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t('models.deleteDialog.title')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete <strong>{modelToDelete?.displayName}</strong>?
|
||||
<Trans
|
||||
i18nKey="models.deleteDialog.body"
|
||||
values={{ name: modelToDelete?.displayName }}
|
||||
components={{ strong: <strong /> }}
|
||||
/>
|
||||
{modelToDelete?.sizeMb && (
|
||||
<>
|
||||
{' '}
|
||||
This will free up {formatSize(modelToDelete.sizeMb)} of disk space. The model will
|
||||
need to be re-downloaded if you want to use it again.
|
||||
{t('models.deleteDialog.sizeNote', { size: formatSize(modelToDelete.sizeMb) })}
|
||||
</>
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
if (modelToDelete) {
|
||||
@@ -931,10 +950,10 @@ export function ModelManagement() {
|
||||
{deleteMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Deleting...
|
||||
{t('models.deleteDialog.deleting')}
|
||||
</>
|
||||
) : (
|
||||
'Delete'
|
||||
t('common.delete')
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
@@ -948,11 +967,8 @@ export function ModelManagement() {
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Move models to new location?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
The server will shut down while models are being moved to the new folder. It will
|
||||
restart automatically once the migration is complete.
|
||||
</AlertDialogDescription>
|
||||
<AlertDialogTitle>{t('models.migrateDialog.title')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{t('models.migrateDialog.description')}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div
|
||||
className="text-xs font-mono text-muted-foreground bg-muted/50 rounded px-3 py-2 truncate"
|
||||
@@ -961,7 +977,7 @@ export function ModelManagement() {
|
||||
{pendingMigrateDir}
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={async () => {
|
||||
if (!pendingMigrateDir) return;
|
||||
@@ -973,7 +989,7 @@ export function ModelManagement() {
|
||||
total: 0,
|
||||
progress: 0,
|
||||
status: 'downloading',
|
||||
filename: 'Preparing...',
|
||||
filename: t('models.migrateDialog.preparing'),
|
||||
});
|
||||
try {
|
||||
// Start the migration (background task)
|
||||
@@ -984,8 +1000,8 @@ export function ModelManagement() {
|
||||
setMigrating(false);
|
||||
setMigrationProgress(null);
|
||||
toast({
|
||||
title: 'No models to migrate',
|
||||
description: 'Download at least one model before changing the storage location.',
|
||||
title: t('models.toast.noModelsToMigrate'),
|
||||
description: t('models.toast.noModelsToMigrateDescription'),
|
||||
});
|
||||
setPendingMigrateDir(null);
|
||||
return;
|
||||
@@ -1003,7 +1019,7 @@ export function ModelManagement() {
|
||||
resolve();
|
||||
} else if (data.status === 'error') {
|
||||
es.close();
|
||||
reject(new Error(data.error || 'Migration failed'));
|
||||
reject(new Error(data.error || t('models.toast.migrationFailed')));
|
||||
}
|
||||
} catch {
|
||||
/* ignore parse errors */
|
||||
@@ -1011,7 +1027,7 @@ export function ModelManagement() {
|
||||
};
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
reject(new Error('Lost connection during migration'));
|
||||
reject(new Error(t('models.toast.migrationConnectionLost')));
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1021,15 +1037,16 @@ export function ModelManagement() {
|
||||
total: 1,
|
||||
progress: 100,
|
||||
status: 'complete',
|
||||
filename: 'Restarting server...',
|
||||
filename: t('models.migrateDialog.restartingServer'),
|
||||
});
|
||||
await platform.lifecycle.restartServer(newDir);
|
||||
queryClient.invalidateQueries();
|
||||
toast({ title: 'Models moved successfully' });
|
||||
toast({ title: t('models.toast.migrated') });
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Migration failed',
|
||||
description: e instanceof Error ? e.message : 'Failed to migrate models',
|
||||
title: t('models.toast.migrationFailed'),
|
||||
description:
|
||||
e instanceof Error ? e.message : t('models.toast.migrationFailedGeneric'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
@@ -1038,7 +1055,7 @@ export function ModelManagement() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
Move Models
|
||||
{t('models.migrateDialog.action')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
@@ -1050,11 +1067,11 @@ export function ModelManagement() {
|
||||
<div className="w-full max-w-md px-8 space-y-6 text-center">
|
||||
<div className="space-y-2">
|
||||
<Loader2 className="h-8 w-8 animate-spin mx-auto text-muted-foreground" />
|
||||
<h2 className="text-lg font-semibold">Moving models</h2>
|
||||
<h2 className="text-lg font-semibold">{t('models.migrate.title')}</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{migrationProgress.status === 'complete'
|
||||
? 'Restarting server...'
|
||||
: 'The server is offline while models are being moved.'}
|
||||
? t('models.migrateDialog.restartingServer')
|
||||
: t('models.migrate.offline')}
|
||||
</p>
|
||||
</div>
|
||||
{migrationProgress.total > 0 && (
|
||||
@@ -1075,4 +1092,3 @@ export function ModelManagement() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ArrowUpRight } from 'lucide-react';
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import voiceboxLogo from '@/assets/voicebox-logo.png';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
|
||||
@@ -16,6 +17,7 @@ function FadeIn({ delay = 0, children }: { delay?: number; children: ReactNode }
|
||||
}
|
||||
|
||||
export function AboutPage() {
|
||||
const { t } = useTranslation();
|
||||
const platform = usePlatform();
|
||||
const [version, setVersion] = useState('');
|
||||
|
||||
@@ -57,14 +59,13 @@ export function AboutPage() {
|
||||
|
||||
<FadeIn delay={160}>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed max-w-sm">
|
||||
The open-source voice synthesis studio. Clone voices, generate speech, apply effects,
|
||||
and build voice-powered apps — all running locally on your machine.
|
||||
{t('settings.about.tagline')}
|
||||
</p>
|
||||
</FadeIn>
|
||||
|
||||
<FadeIn delay={240}>
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<span>Created by</span>
|
||||
<span>{t('settings.about.createdBy')}</span>
|
||||
<a
|
||||
href="https://github.com/jamiepine"
|
||||
target="_blank"
|
||||
@@ -92,7 +93,7 @@ export function AboutPage() {
|
||||
>
|
||||
<path d="m20.216 6.415-.132-.666c-.119-.598-.388-1.163-1.001-1.379-.197-.069-.42-.098-.57-.241-.152-.143-.196-.366-.231-.572-.065-.378-.125-.756-.192-1.133-.057-.325-.102-.69-.25-.987-.195-.4-.597-.634-.996-.788a5.723 5.723 0 0 0-.626-.194c-1-.263-2.05-.36-3.077-.416a25.834 25.834 0 0 0-3.7.062c-.915.083-1.88.184-2.75.5-.318.116-.646.256-.888.501-.297.302-.393.77-.177 1.146.154.267.415.456.692.58.36.162.737.284 1.123.366 1.075.238 2.189.331 3.287.37 1.218.05 2.437.01 3.65-.118.299-.033.598-.073.896-.119.352-.054.578-.513.474-.834-.124-.383-.457-.531-.834-.473-.466.074-.96.108-1.382.146-1.177.08-2.358.082-3.536.006a22.228 22.228 0 0 1-1.157-.107c-.086-.01-.18-.025-.258-.036-.243-.036-.484-.08-.724-.13-.111-.027-.111-.185 0-.212h.005c.277-.06.557-.108.838-.147h.002c.131-.009.263-.032.394-.048a25.076 25.076 0 0 1 3.426-.12c.674.019 1.347.067 2.017.144l.228.031c.267.04.533.088.798.145.392.085.895.113 1.07.542.055.137.08.288.111.431l.319 1.484a.237.237 0 0 1-.199.284h-.003c-.037.006-.075.01-.112.015a36.704 36.704 0 0 1-4.743.295 37.059 37.059 0 0 1-4.699-.304c-.14-.017-.293-.042-.417-.06-.326-.048-.649-.108-.973-.161-.393-.065-.768-.032-1.123.161-.29.16-.527.404-.675.701-.154.316-.199.66-.267 1-.069.34-.176.707-.135 1.056.087.753.613 1.365 1.37 1.502a39.69 39.69 0 0 0 11.343.376.483.483 0 0 1 .535.53l-.071.697-1.018 9.907c-.041.41-.047.832-.125 1.237-.122.637-.553 1.028-1.182 1.171-.577.131-1.165.2-1.756.205-.656.004-1.31-.025-1.966-.022-.699.004-1.556-.06-2.095-.58-.475-.458-.54-1.174-.605-1.793l-.731-7.013-.322-3.094c-.037-.351-.286-.695-.678-.678-.336.015-.718.3-.678.679l.228 2.185.949 9.112c.147 1.344 1.174 2.068 2.446 2.272.742.12 1.503.144 2.257.156.966.016 1.942.053 2.892-.122 1.408-.258 2.465-1.198 2.616-2.657.34-3.332.683-6.663 1.024-9.995l.215-2.087a.484.484 0 0 1 .39-.426c.402-.078.787-.212 1.074-.518.455-.488.546-1.124.385-1.766zm-1.478.772c-.145.137-.363.201-.578.233-2.416.359-4.866.54-7.308.46-1.748-.06-3.477-.254-5.207-.498-.17-.024-.353-.055-.47-.18-.22-.236-.111-.71-.054-.995.052-.26.152-.609.463-.646.484-.057 1.046.148 1.526.22.577.088 1.156.159 1.737.212 2.48.226 5.002.19 7.472-.14.45-.06.899-.13 1.345-.21.399-.072.84-.206 1.08.206.166.281.188.657.162.974a.544.544 0 0 1-.169.364zm-6.159 3.9c-.862.37-1.84.788-3.109.788a5.884 5.884 0 0 1-1.569-.217l.877 9.004c.065.78.717 1.38 1.5 1.38 0 0 1.243.065 1.658.065.447 0 1.786-.065 1.786-.065.783 0 1.434-.6 1.499-1.38l.94-9.95a3.996 3.996 0 0 0-1.322-.238c-.826 0-1.491.284-2.26.613z" />
|
||||
</svg>
|
||||
Buy me a coffee
|
||||
{t('settings.about.buyCoffee')}
|
||||
<ArrowUpRight className="h-3.5 w-3.5 text-muted-foreground/40 group-hover:text-muted-foreground transition-colors" />
|
||||
</a>
|
||||
<a
|
||||
@@ -117,15 +118,20 @@ export function AboutPage() {
|
||||
|
||||
<FadeIn delay={400}>
|
||||
<p className="text-xs text-muted-foreground/40 pt-4">
|
||||
Licensed under{' '}
|
||||
<a
|
||||
href="https://github.com/jamiepine/voicebox/blob/main/LICENSE"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-muted-foreground/60 transition-colors"
|
||||
>
|
||||
MIT
|
||||
</a>
|
||||
<Trans
|
||||
i18nKey="settings.about.license"
|
||||
components={{
|
||||
link: (
|
||||
// biome-ignore lint/a11y/useAnchorContent: Trans fills content at runtime
|
||||
<a
|
||||
href="https://github.com/jamiepine/voicebox/blob/main/LICENSE"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-muted-foreground/60 transition-colors"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</FadeIn>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import changelogRaw from 'virtual:changelog';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { type ChangelogEntry, parseChangelog } from '@/lib/utils/parseChangelog';
|
||||
|
||||
@@ -176,6 +177,7 @@ function inlineMarkdown(text: string): React.ReactNode {
|
||||
}
|
||||
|
||||
function ChangelogEntryCard({ entry }: { entry: ChangelogEntry }) {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const content = useMemo(() => renderMarkdown(entry.body), [entry.body]);
|
||||
const isLong = entry.body.split('\n').length > 12;
|
||||
@@ -185,7 +187,9 @@ function ChangelogEntryCard({ entry }: { entry: ChangelogEntry }) {
|
||||
<div className="flex items-baseline gap-3 mb-3">
|
||||
<h3 className="text-xl font-semibold tracking-tight">{entry.version}</h3>
|
||||
{entry.date && <span className="text-xs text-muted-foreground">{entry.date}</span>}
|
||||
{entry.version === 'Unreleased' && <Badge variant="outline">dev</Badge>}
|
||||
{entry.version === 'Unreleased' && (
|
||||
<Badge variant="outline">{t('settings.changelog.devBadge')}</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={isLong && !expanded ? 'max-h-48 overflow-hidden relative' : ''}>
|
||||
@@ -200,7 +204,7 @@ function ChangelogEntryCard({ entry }: { entry: ChangelogEntry }) {
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="text-xs text-accent hover:underline mt-2"
|
||||
>
|
||||
{expanded ? 'Show less' : 'Show more'}
|
||||
{expanded ? t('settings.changelog.showLess') : t('settings.changelog.showMore')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { AlertCircle, ArrowUpRight, Book, Download, Loader2, RefreshCw } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import * as z from 'zod';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form';
|
||||
@@ -13,15 +14,19 @@ import { useAutoUpdater } from '@/hooks/useAutoUpdater';
|
||||
import { useServerHealth } from '@/lib/hooks/useServer';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
import { LanguageSelect } from './LanguageSelect';
|
||||
import { SettingRow, SettingSection } from './SettingRow';
|
||||
|
||||
const connectionSchema = z.object({
|
||||
serverUrl: z.string().url('Please enter a valid URL'),
|
||||
});
|
||||
function makeConnectionSchema(invalidUrl: string) {
|
||||
return z.object({
|
||||
serverUrl: z.string().url(invalidUrl),
|
||||
});
|
||||
}
|
||||
|
||||
type ConnectionFormValues = z.infer<typeof connectionSchema>;
|
||||
type ConnectionFormValues = { serverUrl: string };
|
||||
|
||||
export function GeneralPage() {
|
||||
const { t } = useTranslation();
|
||||
const platform = usePlatform();
|
||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||
const setServerUrl = useServerStore((state) => state.setServerUrl);
|
||||
@@ -32,8 +37,12 @@ export function GeneralPage() {
|
||||
const { toast } = useToast();
|
||||
const { data: health, isLoading, error: healthError } = useServerHealth();
|
||||
|
||||
const resolver = useMemo(
|
||||
() => zodResolver(makeConnectionSchema(t('settings.general.serverUrl.invalidUrl'))),
|
||||
[t],
|
||||
);
|
||||
const form = useForm<ConnectionFormValues>({
|
||||
resolver: zodResolver(connectionSchema),
|
||||
resolver,
|
||||
defaultValues: { serverUrl },
|
||||
});
|
||||
|
||||
@@ -41,14 +50,21 @@ export function GeneralPage() {
|
||||
form.reset({ serverUrl });
|
||||
}, [serverUrl, form]);
|
||||
|
||||
// Re-run validation when the locale changes so existing error messages retranslate.
|
||||
useEffect(() => {
|
||||
if (form.formState.errors.serverUrl) {
|
||||
form.trigger('serverUrl');
|
||||
}
|
||||
}, [t, form]);
|
||||
|
||||
const { isDirty } = form.formState;
|
||||
|
||||
function onSubmit(data: ConnectionFormValues) {
|
||||
setServerUrl(data.serverUrl);
|
||||
form.reset(data);
|
||||
toast({
|
||||
title: 'Server URL updated',
|
||||
description: `Connected to ${data.serverUrl}`,
|
||||
title: t('settings.general.serverUrl.updatedTitle'),
|
||||
description: t('settings.general.serverUrl.updatedDescription', { url: data.serverUrl }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -63,7 +79,7 @@ export function GeneralPage() {
|
||||
>
|
||||
<Book className="h-5 w-5 shrink-0 text-accent" strokeWidth={2.5} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium">Read the Docs</div>
|
||||
<div className="text-sm font-medium">{t('settings.general.docs.title')}</div>
|
||||
<div className="text-xs text-muted-foreground">docs.voicebox.sh</div>
|
||||
</div>
|
||||
<ArrowUpRight className="h-4 w-4 text-muted-foreground/40 group-hover:text-muted-foreground transition-colors" />
|
||||
@@ -83,8 +99,10 @@ export function GeneralPage() {
|
||||
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
|
||||
</svg>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium">Join the Discord</div>
|
||||
<div className="text-xs text-muted-foreground">Get help & share voices</div>
|
||||
<div className="text-sm font-medium">{t('settings.general.discord.title')}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t('settings.general.discord.subtitle')}
|
||||
</div>
|
||||
</div>
|
||||
<ArrowUpRight className="h-4 w-4 text-muted-foreground/40 group-hover:text-muted-foreground transition-colors" />
|
||||
</a>
|
||||
@@ -92,8 +110,8 @@ export function GeneralPage() {
|
||||
|
||||
<SettingSection>
|
||||
<SettingRow
|
||||
title="Server URL"
|
||||
description="The address of your voicebox backend server."
|
||||
title={t('settings.general.serverUrl.title')}
|
||||
description={t('settings.general.serverUrl.description')}
|
||||
action={
|
||||
<ConnectionStatus health={health} isLoading={isLoading} healthError={healthError} />
|
||||
}
|
||||
@@ -114,7 +132,7 @@ export function GeneralPage() {
|
||||
/>
|
||||
{isDirty && (
|
||||
<Button type="submit" size="sm">
|
||||
Save
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
@@ -122,8 +140,8 @@ export function GeneralPage() {
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Keep server running when app closes"
|
||||
description="The server will continue running in the background after closing the app."
|
||||
title={t('settings.general.keepServerRunning.title')}
|
||||
description={t('settings.general.keepServerRunning.description')}
|
||||
htmlFor="keepServerRunning"
|
||||
action={
|
||||
<Toggle
|
||||
@@ -135,17 +153,17 @@ export function GeneralPage() {
|
||||
console.error('Failed to sync setting to Rust:', error);
|
||||
setKeepServerRunningOnClose(!checked);
|
||||
toast({
|
||||
title: 'Failed to update setting',
|
||||
description: 'Could not sync setting to backend.',
|
||||
title: t('settings.general.keepServerRunning.failedTitle'),
|
||||
description: t('settings.general.keepServerRunning.failedDescription'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
});
|
||||
toast({
|
||||
title: 'Setting updated',
|
||||
title: t('settings.general.keepServerRunning.updatedTitle'),
|
||||
description: checked
|
||||
? 'Server will continue running when app closes'
|
||||
: 'Server will stop when app closes',
|
||||
? t('settings.general.keepServerRunning.runningDescription')
|
||||
: t('settings.general.keepServerRunning.stoppedDescription'),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
@@ -154,8 +172,8 @@ export function GeneralPage() {
|
||||
|
||||
{platform.metadata.isTauri && (
|
||||
<SettingRow
|
||||
title="Allow network access"
|
||||
description="Makes the server accessible from other devices on your network. Restart the app after changing."
|
||||
title={t('settings.general.networkAccess.title')}
|
||||
description={t('settings.general.networkAccess.description')}
|
||||
htmlFor="allowNetworkAccess"
|
||||
action={
|
||||
<Toggle
|
||||
@@ -164,16 +182,22 @@ export function GeneralPage() {
|
||||
onCheckedChange={(checked: boolean) => {
|
||||
setMode(checked ? 'remote' : 'local');
|
||||
toast({
|
||||
title: 'Setting updated',
|
||||
title: t('settings.general.networkAccess.updatedTitle'),
|
||||
description: checked
|
||||
? 'Network access enabled. Restart the app to apply.'
|
||||
: 'Network access disabled. Restart the app to apply.',
|
||||
? t('settings.general.networkAccess.enabled')
|
||||
: t('settings.general.networkAccess.disabled'),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SettingRow
|
||||
title={t('settings.language.label')}
|
||||
description={t('settings.language.description')}
|
||||
action={<LanguageSelect />}
|
||||
/>
|
||||
</SettingSection>
|
||||
|
||||
<ApiReferenceCard serverUrl={serverUrl} />
|
||||
@@ -192,11 +216,14 @@ function ConnectionStatus({
|
||||
isLoading: boolean;
|
||||
healthError: ReturnType<typeof useServerHealth>['error'];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-full border border-border/60 px-3 py-1">
|
||||
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">Connecting</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('settings.general.connection.connecting')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -207,7 +234,7 @@ function ConnectionStatus({
|
||||
<span className="absolute inline-flex h-full w-full rounded-full bg-destructive/40" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-destructive" />
|
||||
</span>
|
||||
<span className="text-xs text-destructive">Offline</span>
|
||||
<span className="text-xs text-destructive">{t('settings.general.connection.offline')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -218,7 +245,9 @@ function ConnectionStatus({
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-accent/60" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-accent shadow-[0_0_6px_1px_hsl(var(--accent)/0.5)]" />
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">Online</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('settings.general.connection.online')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -226,35 +255,41 @@ function ConnectionStatus({
|
||||
}
|
||||
|
||||
function UpdatesSection() {
|
||||
const { t } = useTranslation();
|
||||
const platform = usePlatform();
|
||||
const { status, checkForUpdates, downloadAndInstall, restartAndInstall } = useAutoUpdater(false);
|
||||
const [currentVersion, setCurrentVersion] = useState<string>('');
|
||||
const [currentVersion, setCurrentVersion] = useState<string | null>('');
|
||||
const isDev = !import.meta.env?.PROD;
|
||||
|
||||
useEffect(() => {
|
||||
platform.metadata
|
||||
.getVersion()
|
||||
.then(setCurrentVersion)
|
||||
.catch(() => setCurrentVersion('Unknown'));
|
||||
.catch(() => setCurrentVersion(null));
|
||||
}, [platform]);
|
||||
|
||||
const versionLabel = currentVersion ?? t('common.unknown');
|
||||
|
||||
return (
|
||||
<SettingSection title="App Updates" description={`v${currentVersion}${isDev ? ' (dev)' : ''}`}>
|
||||
<SettingSection
|
||||
title={t('settings.general.updates.title')}
|
||||
description={`v${versionLabel}${isDev ? t('settings.general.updates.devSuffix') : ''}`}
|
||||
>
|
||||
{isDev ? (
|
||||
<SettingRow
|
||||
title="Development mode"
|
||||
description="Auto-updates are disabled in development mode."
|
||||
title={t('settings.general.updates.devMode.title')}
|
||||
description={t('settings.general.updates.devMode.description')}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<SettingRow
|
||||
title="Check for updates"
|
||||
title={t('settings.general.updates.check.title')}
|
||||
description={
|
||||
status.available
|
||||
? `Version ${status.version} available`
|
||||
? t('settings.general.updates.check.available', { version: status.version })
|
||||
: status.checking
|
||||
? 'Checking...'
|
||||
: "You're up to date"
|
||||
? t('settings.general.updates.check.checking')
|
||||
: t('settings.general.updates.check.upToDate')
|
||||
}
|
||||
action={
|
||||
<Button
|
||||
@@ -266,13 +301,13 @@ function UpdatesSection() {
|
||||
<RefreshCw
|
||||
className={`h-3.5 w-3.5 mr-1.5 ${status.checking ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
Check
|
||||
{t('settings.general.updates.check.button')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{status.error && (
|
||||
<SettingRow title="Update error">
|
||||
<SettingRow title={t('settings.general.updates.error')}>
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{status.error}
|
||||
@@ -282,19 +317,19 @@ function UpdatesSection() {
|
||||
|
||||
{status.available && !status.downloading && !status.readyToInstall && (
|
||||
<SettingRow
|
||||
title={`Update to ${status.version}`}
|
||||
description="Download and install the latest version."
|
||||
title={t('settings.general.updates.download.title', { version: status.version })}
|
||||
description={t('settings.general.updates.download.description')}
|
||||
action={
|
||||
<Button onClick={downloadAndInstall} size="sm">
|
||||
<Download className="h-3.5 w-3.5 mr-1.5" />
|
||||
Download
|
||||
{t('settings.general.updates.download.button')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{status.downloading && (
|
||||
<SettingRow title="Downloading update...">
|
||||
<SettingRow title={t('settings.general.updates.downloading')}>
|
||||
<div className="space-y-1.5">
|
||||
<Progress value={status.downloadProgress} />
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
@@ -316,12 +351,14 @@ function UpdatesSection() {
|
||||
|
||||
{status.readyToInstall && (
|
||||
<SettingRow
|
||||
title="Update ready to install"
|
||||
description={`Version ${status.version} has been downloaded. Restart to complete.`}
|
||||
title={t('settings.general.updates.ready.title')}
|
||||
description={t('settings.general.updates.ready.description', {
|
||||
version: status.version,
|
||||
})}
|
||||
action={
|
||||
<Button onClick={restartAndInstall} size="sm">
|
||||
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
|
||||
Restart Now
|
||||
{t('settings.general.updates.ready.button')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
@@ -332,25 +369,31 @@ function UpdatesSection() {
|
||||
);
|
||||
}
|
||||
|
||||
const API_ENDPOINTS = [
|
||||
{ method: 'POST', path: '/generate', label: 'Generate speech' },
|
||||
{ method: 'GET', path: '/health', label: 'Server status' },
|
||||
{ method: 'GET', path: '/profiles', label: 'List voices' },
|
||||
{ method: 'GET', path: '/history', label: 'Past generations' },
|
||||
];
|
||||
|
||||
function ApiReferenceCard({ serverUrl }: { serverUrl: string }) {
|
||||
const { t } = useTranslation();
|
||||
const endpoints = [
|
||||
{ method: 'POST', path: '/generate', label: t('settings.general.api.endpoints.generate') },
|
||||
{ method: 'GET', path: '/health', label: t('settings.general.api.endpoints.health') },
|
||||
{ method: 'GET', path: '/profiles', label: t('settings.general.api.endpoints.profiles') },
|
||||
{ method: 'GET', path: '/history', label: t('settings.general.api.endpoints.history') },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border/60 p-4 space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">API Access</h3>
|
||||
<h3 className="text-sm font-medium">{t('settings.general.api.title')}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Integrate Voicebox into your workflow via the REST API at{' '}
|
||||
<code className="text-xs bg-muted px-1 py-0.5 rounded font-mono">{serverUrl}</code>
|
||||
<Trans
|
||||
i18nKey="settings.general.api.description"
|
||||
values={{ url: serverUrl }}
|
||||
components={{
|
||||
code: <code className="text-xs bg-muted px-1 py-0.5 rounded font-mono" />,
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{API_ENDPOINTS.map((ep) => (
|
||||
{endpoints.map((ep) => (
|
||||
<div key={ep.path} className="flex items-center gap-2.5 py-1">
|
||||
<span
|
||||
className={`text-[10px] font-mono font-semibold w-9 text-center rounded px-1 py-px ${
|
||||
@@ -371,7 +414,7 @@ function ApiReferenceCard({ serverUrl }: { serverUrl: string }) {
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
View the full API reference
|
||||
{t('settings.general.api.viewReference')}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { FolderOpen } from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { Toggle } from '@/components/ui/toggle';
|
||||
@@ -8,6 +9,7 @@ import { useServerStore } from '@/stores/serverStore';
|
||||
import { SettingRow, SettingSection } from './SettingRow';
|
||||
|
||||
export function GenerationPage() {
|
||||
const { t } = useTranslation();
|
||||
const platform = usePlatform();
|
||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||
const maxChunkChars = useServerStore((state) => state.maxChunkChars);
|
||||
@@ -48,15 +50,15 @@ export function GenerationPage() {
|
||||
return (
|
||||
<div className="space-y-8 max-w-2xl">
|
||||
<SettingSection
|
||||
title="Generation"
|
||||
description="Controls for long text generation. These settings apply to all engines."
|
||||
title={t('settings.generation.title')}
|
||||
description={t('settings.generation.description')}
|
||||
>
|
||||
<SettingRow
|
||||
title="Auto-chunking limit"
|
||||
description="Long text is split into chunks at sentence boundaries. Lower values can improve quality for long outputs."
|
||||
title={t('settings.generation.chunkLimit.title')}
|
||||
description={t('settings.generation.chunkLimit.description')}
|
||||
action={
|
||||
<span className="text-sm tabular-nums text-muted-foreground">
|
||||
{maxChunkChars} chars
|
||||
{t('settings.generation.chunkLimit.value', { chars: maxChunkChars })}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
@@ -67,16 +69,18 @@ export function GenerationPage() {
|
||||
min={100}
|
||||
max={5000}
|
||||
step={50}
|
||||
aria-label="Auto-chunking character limit"
|
||||
aria-label={t('settings.generation.chunkLimit.title')}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Chunk crossfade"
|
||||
description="Blends audio between chunks to smooth transitions. Set to 0 for a hard cut."
|
||||
title={t('settings.generation.crossfade.title')}
|
||||
description={t('settings.generation.crossfade.description')}
|
||||
action={
|
||||
<span className="text-sm tabular-nums text-muted-foreground">
|
||||
{crossfadeMs === 0 ? 'Cut' : `${crossfadeMs}ms`}
|
||||
{crossfadeMs === 0
|
||||
? t('settings.generation.crossfade.cut')
|
||||
: t('settings.generation.crossfade.ms', { ms: crossfadeMs })}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
@@ -87,13 +91,13 @@ export function GenerationPage() {
|
||||
min={0}
|
||||
max={200}
|
||||
step={10}
|
||||
aria-label="Chunk crossfade duration"
|
||||
aria-label={t('settings.generation.crossfade.title')}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Normalize audio"
|
||||
description="Adjusts output volume to a consistent level across generations."
|
||||
title={t('settings.generation.normalize.title')}
|
||||
description={t('settings.generation.normalize.description')}
|
||||
htmlFor="normalizeAudio"
|
||||
action={
|
||||
<Toggle
|
||||
@@ -105,8 +109,8 @@ export function GenerationPage() {
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
title="Autoplay on generate"
|
||||
description="Automatically play audio when a generation completes."
|
||||
title={t('settings.generation.autoplay.title')}
|
||||
description={t('settings.generation.autoplay.description')}
|
||||
htmlFor="autoplayOnGenerate"
|
||||
action={
|
||||
<Toggle
|
||||
@@ -118,8 +122,8 @@ export function GenerationPage() {
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
title="Generations folder"
|
||||
description={generationsPath ?? 'Where generated audio files are stored on disk.'}
|
||||
title={t('settings.generation.folder.title')}
|
||||
description={generationsPath ?? t('settings.generation.folder.description')}
|
||||
action={
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -128,7 +132,7 @@ export function GenerationPage() {
|
||||
disabled={opening || !generationsPath}
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5 mr-1.5" />
|
||||
Open
|
||||
{t('settings.generation.folder.open')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { AlertCircle, Cpu, Download, Loader2, RotateCw, Trash2 } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
@@ -40,9 +41,9 @@ function GpuIcon({ className }: { className?: string }) {
|
||||
}
|
||||
|
||||
function GpuInfoCard({ health }: { health: HealthResponse }) {
|
||||
const { t } = useTranslation();
|
||||
const hasGpu = health.gpu_available && health.gpu_type;
|
||||
|
||||
// Parse GPU name from type string like "CUDA (NVIDIA RTX 4090)" or "MPS (Apple M2 Pro)"
|
||||
const gpuName = hasGpu
|
||||
? health.gpu_type!.replace(/^(CUDA|ROCm|MPS|Metal|XPU|DirectML)\s*\((.+)\)$/, '$2') ||
|
||||
health.gpu_type!
|
||||
@@ -64,7 +65,7 @@ function GpuInfoCard({ health }: { health: HealthResponse }) {
|
||||
<Cpu className="h-5 w-5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0 space-y-0.5">
|
||||
<div className="text-sm font-medium">{hasGpu ? gpuName : 'CPU Only'}</div>
|
||||
<div className="text-sm font-medium">{hasGpu ? gpuName : t('settings.gpu.cpuOnly')}</div>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
{hasGpu ? (
|
||||
<>
|
||||
@@ -78,12 +79,14 @@ function GpuInfoCard({ health }: { health: HealthResponse }) {
|
||||
{health.vram_used_mb != null && health.vram_used_mb > 0 && (
|
||||
<>
|
||||
<span className="text-border">|</span>
|
||||
<span>{health.vram_used_mb.toFixed(0)} MB VRAM</span>
|
||||
<span>
|
||||
{t('settings.gpu.vramUsed', { mb: health.vram_used_mb.toFixed(0) })}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span>No GPU acceleration detected</span>
|
||||
<span>{t('settings.gpu.noAcceleration')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -93,7 +96,9 @@ function GpuInfoCard({ health }: { health: HealthResponse }) {
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-accent/60" />
|
||||
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-accent shadow-[0_0_4px_1px_hsl(var(--accent)/0.4)]" />
|
||||
</span>
|
||||
<span className="text-[10px] font-medium text-muted-foreground">Active</span>
|
||||
<span className="text-[10px] font-medium text-muted-foreground">
|
||||
{t('settings.gpu.active')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -102,6 +107,7 @@ function GpuInfoCard({ health }: { health: HealthResponse }) {
|
||||
}
|
||||
|
||||
export function GpuPage() {
|
||||
const { t } = useTranslation();
|
||||
const platform = usePlatform();
|
||||
const queryClient = useQueryClient();
|
||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||
@@ -111,6 +117,12 @@ export function GpuPage() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [downloadProgress, setDownloadProgress] = useState<CudaDownloadProgress | null>(null);
|
||||
const healthPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
// Hold the latest `t` in a ref so the CUDA progress SSE effect below doesn't
|
||||
// tear down and reconnect the EventSource every time the language changes.
|
||||
const tRef = useRef(t);
|
||||
useEffect(() => {
|
||||
tRef.current = t;
|
||||
}, [t]);
|
||||
|
||||
const {
|
||||
data: cudaStatus,
|
||||
@@ -153,7 +165,7 @@ export function GpuPage() {
|
||||
refetchCudaStatus();
|
||||
} else if (data.status === 'error') {
|
||||
eventSource.close();
|
||||
setError(data.error || 'Download failed');
|
||||
setError(data.error || tRef.current('settings.gpu.errors.downloadFailed'));
|
||||
setDownloadProgress(null);
|
||||
refetchCudaStatus();
|
||||
}
|
||||
@@ -218,7 +230,7 @@ export function GpuPage() {
|
||||
await apiClient.downloadCudaBackend();
|
||||
refetchCudaStatus();
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : 'Failed to start download';
|
||||
const msg = e instanceof Error ? e.message : t('settings.gpu.errors.downloadStart');
|
||||
if (msg.includes('already downloaded')) {
|
||||
refetchCudaStatus();
|
||||
} else {
|
||||
@@ -230,9 +242,9 @@ export function GpuPage() {
|
||||
const handleRestart = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
await restartServerWithPolling('Restart failed');
|
||||
await restartServerWithPolling(t('settings.gpu.errors.restartFailed'));
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Restart failed');
|
||||
setError(e instanceof Error ? e.message : t('settings.gpu.errors.restartFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -241,9 +253,9 @@ export function GpuPage() {
|
||||
setRestartPhase('stopping');
|
||||
try {
|
||||
await apiClient.deleteCudaBackend();
|
||||
await restartServerWithPolling('Failed to switch to CPU');
|
||||
await restartServerWithPolling(t('settings.gpu.errors.switchCpu'));
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to switch to CPU');
|
||||
setError(e instanceof Error ? e.message : t('settings.gpu.errors.switchCpu'));
|
||||
refetchCudaStatus();
|
||||
}
|
||||
};
|
||||
@@ -254,7 +266,7 @@ export function GpuPage() {
|
||||
await apiClient.deleteCudaBackend();
|
||||
refetchCudaStatus();
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to delete CUDA backend');
|
||||
setError(e instanceof Error ? e.message : t('settings.gpu.errors.deleteCuda'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -278,21 +290,21 @@ export function GpuPage() {
|
||||
<div className="space-y-8 max-w-2xl">
|
||||
<GpuInfoCard health={health} />
|
||||
|
||||
{/* CUDA section — only when no native GPU and not already on CUDA */}
|
||||
{!hasNativeGpu && !isCurrentlyCuda && (
|
||||
<SettingSection
|
||||
title="CUDA Backend"
|
||||
description="NVIDIA GPU acceleration via a downloadable CUDA backend."
|
||||
title={t('settings.gpu.cuda.title')}
|
||||
description={t('settings.gpu.cuda.description')}
|
||||
>
|
||||
{/* Download progress */}
|
||||
{cudaDownloading && downloadProgress && (
|
||||
<SettingRow title="Downloading CUDA backend...">
|
||||
<SettingRow title={t('settings.gpu.cuda.downloading')}>
|
||||
<div className="space-y-1.5">
|
||||
<Progress value={downloadProgress.progress} className="h-2" />
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{downloadProgress.filename ||
|
||||
(cudaAvailable ? 'Updating...' : 'Downloading...')}
|
||||
(cudaAvailable
|
||||
? t('settings.gpu.cuda.updating')
|
||||
: t('settings.gpu.cuda.downloadingShort'))}
|
||||
</span>
|
||||
<span>
|
||||
{downloadProgress.total > 0
|
||||
@@ -304,23 +316,21 @@ export function GpuPage() {
|
||||
</SettingRow>
|
||||
)}
|
||||
|
||||
{/* Restart in progress */}
|
||||
{restartPhase !== 'idle' && (
|
||||
<SettingRow
|
||||
title={
|
||||
restartPhase === 'ready'
|
||||
? 'Server restarted successfully'
|
||||
? t('settings.gpu.restart.ready')
|
||||
: restartPhase === 'waiting'
|
||||
? 'Restarting server...'
|
||||
: 'Stopping server...'
|
||||
? t('settings.gpu.restart.waiting')
|
||||
: t('settings.gpu.restart.stopping')
|
||||
}
|
||||
action={<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<SettingRow title="Error">
|
||||
<SettingRow title={t('common.error')}>
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
@@ -328,17 +338,16 @@ export function GpuPage() {
|
||||
</SettingRow>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{restartPhase === 'idle' && !cudaDownloading && (
|
||||
<>
|
||||
{!cudaAvailable && !isCurrentlyCuda && (
|
||||
<SettingRow
|
||||
title="Download CUDA backend"
|
||||
description="~2.4 GB download. Requires an NVIDIA GPU with CUDA support."
|
||||
title={t('settings.gpu.download.title')}
|
||||
description={t('settings.gpu.download.description')}
|
||||
action={
|
||||
<Button onClick={handleDownload} size="sm">
|
||||
<Download className="h-3.5 w-3.5 mr-1.5" />
|
||||
Download
|
||||
{t('settings.gpu.download.button')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
@@ -346,12 +355,12 @@ export function GpuPage() {
|
||||
|
||||
{cudaAvailable && !isCurrentlyCuda && platform.metadata.isTauri && (
|
||||
<SettingRow
|
||||
title="Switch to CUDA backend"
|
||||
description="CUDA backend is downloaded and ready. Restart to enable."
|
||||
title={t('settings.gpu.switchToCuda.title')}
|
||||
description={t('settings.gpu.switchToCuda.description')}
|
||||
action={
|
||||
<Button onClick={handleRestart} size="sm">
|
||||
<RotateCw className="h-3.5 w-3.5 mr-1.5" />
|
||||
Restart
|
||||
{t('settings.gpu.switchToCuda.button')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
@@ -359,12 +368,12 @@ export function GpuPage() {
|
||||
|
||||
{isCurrentlyCuda && platform.metadata.isTauri && (
|
||||
<SettingRow
|
||||
title="Switch to CPU backend"
|
||||
description="Disable GPU acceleration. You can re-download CUDA later."
|
||||
title={t('settings.gpu.switchToCpu.title')}
|
||||
description={t('settings.gpu.switchToCpu.description')}
|
||||
action={
|
||||
<Button onClick={handleSwitchToCpu} variant="outline" size="sm">
|
||||
<RotateCw className="h-3.5 w-3.5 mr-1.5" />
|
||||
Switch
|
||||
{t('settings.gpu.switchToCpu.button')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
@@ -372,8 +381,8 @@ export function GpuPage() {
|
||||
|
||||
{cudaAvailable && !isCurrentlyCuda && (
|
||||
<SettingRow
|
||||
title="Remove CUDA backend"
|
||||
description="Delete the downloaded CUDA binary to free disk space."
|
||||
title={t('settings.gpu.remove.title')}
|
||||
description={t('settings.gpu.remove.description')}
|
||||
action={
|
||||
<Button
|
||||
onClick={handleDelete}
|
||||
@@ -382,7 +391,7 @@ export function GpuPage() {
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
|
||||
Remove
|
||||
{t('settings.gpu.remove.button')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
@@ -392,14 +401,7 @@ export function GpuPage() {
|
||||
</SettingSection>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground/60 leading-relaxed">
|
||||
Voicebox automatically detects and uses the best available GPU on your system. On Apple
|
||||
Silicon Macs, the MLX backend runs natively on the Neural Engine and GPU via Metal
|
||||
Performance Shaders (MPS), with no additional setup required. On Windows and Linux with
|
||||
NVIDIA GPUs, you can download an optional CUDA backend for hardware-accelerated inference.
|
||||
AMD ROCm, Intel XPU, and DirectML are also supported where available through PyTorch. When
|
||||
no GPU is detected, Voicebox falls back to CPU — all engines still work, just slower.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/60 leading-relaxed">{t('settings.gpu.footer')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { type LanguageCode, SUPPORTED_LANGUAGES } from '@/i18n';
|
||||
|
||||
export function LanguageSelect() {
|
||||
const { i18n } = useTranslation();
|
||||
const current = SUPPORTED_LANGUAGES.find((l) => l.code === i18n.language)?.code ?? 'en';
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={current}
|
||||
onValueChange={(value) => {
|
||||
void i18n.changeLanguage(value as LanguageCode);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-[180px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SUPPORTED_LANGUAGES.map((lang) => (
|
||||
<SelectItem key={lang.code} value={lang.code}>
|
||||
{lang.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { type LogEntry, useLogStore } from '@/stores/logStore';
|
||||
@@ -32,6 +33,7 @@ function LogLine({ entry }: { entry: LogEntry }) {
|
||||
}
|
||||
|
||||
export function LogsPage() {
|
||||
const { t } = useTranslation();
|
||||
const entries = useLogStore((s) => s.entries);
|
||||
const clear = useLogStore((s) => s.clear);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -56,9 +58,9 @@ export function LogsPage() {
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">Server Logs</h3>
|
||||
<h3 className="text-sm font-medium">{t('settings.logs.title')}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{entries.length} {entries.length === 1 ? 'line' : 'lines'}
|
||||
{t('settings.logs.lineCount', { count: entries.length })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -71,11 +73,11 @@ export function LogsPage() {
|
||||
containerRef.current?.scrollTo({ top: containerRef.current.scrollHeight });
|
||||
}}
|
||||
>
|
||||
Scroll to bottom
|
||||
{t('settings.logs.scrollToBottom')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={clear}>
|
||||
Clear
|
||||
{t('settings.logs.clear')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -87,13 +89,8 @@ export function LogsPage() {
|
||||
>
|
||||
{entries.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground/50 font-mono space-y-1">
|
||||
<p>No log output yet.</p>
|
||||
{!import.meta.env?.PROD && (
|
||||
<p>
|
||||
Server logs are only captured when the app manages the server process (production
|
||||
builds).
|
||||
</p>
|
||||
)}
|
||||
<p>{t('settings.logs.empty')}</p>
|
||||
{!import.meta.env?.PROD && <p>{t('settings.logs.devHint')}</p>}
|
||||
</div>
|
||||
) : (
|
||||
entries.map((entry) => <LogLine key={entry.id} entry={entry} />)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Link, Outlet, useMatchRoute } from '@tanstack/react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
|
||||
interface SettingsTab {
|
||||
label: string;
|
||||
labelKey: string;
|
||||
path:
|
||||
| '/settings'
|
||||
| '/settings/generation'
|
||||
@@ -17,15 +18,16 @@ interface SettingsTab {
|
||||
}
|
||||
|
||||
const tabs: SettingsTab[] = [
|
||||
{ label: 'General', path: '/settings' },
|
||||
{ label: 'Generation', path: '/settings/generation' },
|
||||
{ label: 'GPU', path: '/settings/gpu', tauriOnly: true },
|
||||
{ label: 'Logs', path: '/settings/logs', tauriOnly: true },
|
||||
{ label: 'Changelog', path: '/settings/changelog' },
|
||||
{ label: 'About', path: '/settings/about' },
|
||||
{ labelKey: 'settings.tabs.general', path: '/settings' },
|
||||
{ labelKey: 'settings.tabs.generation', path: '/settings/generation' },
|
||||
{ labelKey: 'settings.tabs.gpu', path: '/settings/gpu', tauriOnly: true },
|
||||
{ labelKey: 'settings.tabs.logs', path: '/settings/logs', tauriOnly: true },
|
||||
{ labelKey: 'settings.tabs.changelog', path: '/settings/changelog' },
|
||||
{ labelKey: 'settings.tabs.about', path: '/settings/about' },
|
||||
];
|
||||
|
||||
export function SettingsLayout() {
|
||||
const { t } = useTranslation();
|
||||
const platform = usePlatform();
|
||||
const isPlayerVisible = !!usePlayerStore((state) => state.audioUrl);
|
||||
const matchRoute = useMatchRoute();
|
||||
@@ -52,7 +54,7 @@ export function SettingsLayout() {
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-muted-foreground/30',
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
{t(tab.labelKey)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Link, useMatchRoute } from '@tanstack/react-router';
|
||||
import { AudioLines, Box, Mic, Settings, Speaker, Volume2, Wand2 } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import voiceboxLogo from '@/assets/voicebox-logo.png';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
@@ -13,16 +14,17 @@ interface SidebarProps {
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: 'main', path: '/', icon: Volume2, label: 'Generate' },
|
||||
{ id: 'stories', path: '/stories', icon: AudioLines, label: 'Stories' },
|
||||
{ id: 'voices', path: '/voices', icon: Mic, label: 'Voices' },
|
||||
{ id: 'effects', path: '/effects', icon: Wand2, label: 'Effects' },
|
||||
{ id: 'audio', path: '/audio', icon: Speaker, label: 'Audio' },
|
||||
{ id: 'models', path: '/models', icon: Box, label: 'Models' },
|
||||
{ id: 'settings', path: '/settings', icon: Settings, label: 'Settings' },
|
||||
{ id: 'main', path: '/', icon: Volume2, labelKey: 'nav.generate' },
|
||||
{ id: 'stories', path: '/stories', icon: AudioLines, labelKey: 'nav.stories' },
|
||||
{ id: 'voices', path: '/voices', icon: Mic, labelKey: 'nav.voices' },
|
||||
{ id: 'effects', path: '/effects', icon: Wand2, labelKey: 'nav.effects' },
|
||||
{ id: 'audio', path: '/audio', icon: Speaker, labelKey: 'nav.audio' },
|
||||
{ id: 'models', path: '/models', icon: Box, labelKey: 'nav.models' },
|
||||
{ id: 'settings', path: '/settings', icon: Settings, labelKey: 'nav.settings' },
|
||||
];
|
||||
|
||||
export function Sidebar({ isMacOS }: SidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
const matchRoute = useMatchRoute();
|
||||
const isPlayerOpen = !!usePlayerStore((s) => s.audioUrl);
|
||||
const platform = usePlatform();
|
||||
@@ -72,8 +74,8 @@ export function Sidebar({ isMacOS }: SidebarProps) {
|
||||
? 'bg-white/[0.07] text-foreground shadow-lg backdrop-blur-sm border border-white/[0.08]'
|
||||
: 'text-muted-foreground hover:bg-muted/50',
|
||||
)}
|
||||
title={tab.label}
|
||||
aria-label={tab.label}
|
||||
title={t(tab.labelKey)}
|
||||
aria-label={t(tab.labelKey)}
|
||||
>
|
||||
{isActive && (
|
||||
<div
|
||||
@@ -102,7 +104,7 @@ export function Sidebar({ isMacOS }: SidebarProps) {
|
||||
to="/settings"
|
||||
className="text-[9px] font-semibold tracking-wide uppercase px-2 py-0.5 rounded-full bg-accent/15 text-accent hover:bg-accent/25 transition-colors"
|
||||
>
|
||||
Update
|
||||
{t('nav.updateBadge')}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { GripVertical, Mic, MoreHorizontal, Play, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -34,6 +35,7 @@ export function StoryChatItem({
|
||||
dragHandleProps,
|
||||
isDragging,
|
||||
}: StoryChatItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const seek = useStoryStore((state) => state.seek);
|
||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||
const [avatarError, setAvatarError] = useState(false);
|
||||
@@ -118,21 +120,26 @@ export function StoryChatItem({
|
||||
<div className="shrink-0">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Actions">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
aria-label={t('history.actions.menu')}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={handlePlay}>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Play from here
|
||||
{t('storyContent.itemActions.playFromHere')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={onRemove}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Remove from Story
|
||||
{t('storyContent.itemActions.removeFromStory')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { Link } from '@tanstack/react-router';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { Download, Plus } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Loader from 'react-loaders';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -36,6 +37,7 @@ import { useStoryStore } from '@/stores/storyStore';
|
||||
import { SortableStoryChatItem } from './StoryChatItem';
|
||||
|
||||
export function StoryContent() {
|
||||
const { t } = useTranslation();
|
||||
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
||||
const { data: story, isLoading } = useStory(selectedStoryId);
|
||||
const removeItem = useRemoveStoryItem();
|
||||
@@ -147,7 +149,7 @@ export function StoryContent() {
|
||||
{
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: 'Failed to remove item',
|
||||
title: t('storyContent.toast.removeFailed'),
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
@@ -179,7 +181,7 @@ export function StoryContent() {
|
||||
{
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: 'Failed to reorder items',
|
||||
title: t('storyContent.toast.reorderFailed'),
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
@@ -199,7 +201,7 @@ export function StoryContent() {
|
||||
{
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: 'Failed to export audio',
|
||||
title: t('storyContent.toast.exportFailed'),
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
@@ -223,7 +225,7 @@ export function StoryContent() {
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: 'Failed to add generation',
|
||||
title: t('storyContent.toast.addFailed'),
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
@@ -236,8 +238,8 @@ export function StoryContent() {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-medium mb-2">Select a story</p>
|
||||
<p className="text-sm">Choose a story from the list to view its content</p>
|
||||
<p className="text-lg font-medium mb-2">{t('storyContent.selectStory.title')}</p>
|
||||
<p className="text-sm">{t('storyContent.selectStory.hint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -246,7 +248,7 @@ export function StoryContent() {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-muted-foreground">Loading story...</div>
|
||||
<div className="text-muted-foreground">{t('storyContent.loading')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -255,8 +257,8 @@ export function StoryContent() {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-medium mb-2">Story not found</p>
|
||||
<p className="text-sm">The selected story could not be loaded</p>
|
||||
<p className="text-lg font-medium mb-2">{t('storyContent.notFound.title')}</p>
|
||||
<p className="text-sm">{t('storyContent.notFound.hint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -291,7 +293,7 @@ export function StoryContent() {
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
Generating {pendingCount} {pendingCount === 1 ? 'audio' : 'audios'}
|
||||
{t('storyContent.generatingCount', { count: pendingCount })}
|
||||
</span>
|
||||
</Link>
|
||||
</motion.div>
|
||||
@@ -301,13 +303,13 @@ export function StoryContent() {
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add
|
||||
{t('storyContent.add')}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 p-0" align="end">
|
||||
<div className="p-2 border-b">
|
||||
<Input
|
||||
placeholder="Search by name or transcript..."
|
||||
placeholder={t('storyContent.searchPlaceholder')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
autoFocus
|
||||
@@ -316,7 +318,9 @@ export function StoryContent() {
|
||||
<div className="max-h-60 overflow-y-auto">
|
||||
{availableGenerations.length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||
{searchQuery ? 'No matching generations found' : 'No available generations'}
|
||||
{searchQuery
|
||||
? t('storyContent.searchNoMatches')
|
||||
: t('storyContent.searchNoAvailable')}
|
||||
</div>
|
||||
) : (
|
||||
availableGenerations.map((gen) => (
|
||||
@@ -344,7 +348,7 @@ export function StoryContent() {
|
||||
disabled={exportAudio.isPending}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export Audio
|
||||
{t('storyContent.exportAudio')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -358,8 +362,8 @@ export function StoryContent() {
|
||||
>
|
||||
{sortedItems.length === 0 ? (
|
||||
<div className="text-center py-12 px-5 border-2 border-dashed border-muted rounded-md text-muted-foreground">
|
||||
<p className="text-sm">No items in this story</p>
|
||||
<p className="text-xs mt-2">Generate speech using the box below to add items</p>
|
||||
<p className="text-sm">{t('storyContent.empty.title')}</p>
|
||||
<p className="text-xs mt-2">{t('storyContent.empty.hint')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<DndContext
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BookOpen, MoreHorizontal, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -41,6 +42,7 @@ import { formatDate } from '@/lib/utils/format';
|
||||
import { useStoryStore } from '@/stores/storyStore';
|
||||
|
||||
export function StoryList() {
|
||||
const { t } = useTranslation();
|
||||
const { data: stories, isLoading } = useStories();
|
||||
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
||||
const setSelectedStoryId = useStoryStore((state) => state.setSelectedStoryId);
|
||||
@@ -72,8 +74,8 @@ export function StoryList() {
|
||||
const handleCreateStory = () => {
|
||||
if (!newStoryName.trim()) {
|
||||
toast({
|
||||
title: 'Name required',
|
||||
description: 'Please enter a story name',
|
||||
title: t('stories.toast.nameRequired'),
|
||||
description: t('stories.toast.nameRequiredDescription'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
@@ -91,13 +93,13 @@ export function StoryList() {
|
||||
setNewStoryName('');
|
||||
setNewStoryDescription('');
|
||||
toast({
|
||||
title: 'Story created',
|
||||
description: `"${story.name}" has been created`,
|
||||
title: t('stories.toast.created'),
|
||||
description: t('stories.toast.createdDescription', { name: story.name }),
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: 'Failed to create story',
|
||||
title: t('stories.toast.createFailed'),
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
@@ -116,8 +118,8 @@ export function StoryList() {
|
||||
const handleUpdateStory = () => {
|
||||
if (!editingStory || !newStoryName.trim()) {
|
||||
toast({
|
||||
title: 'Name required',
|
||||
description: 'Please enter a story name',
|
||||
title: t('stories.toast.nameRequired'),
|
||||
description: t('stories.toast.nameRequiredDescription'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
@@ -140,7 +142,7 @@ export function StoryList() {
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: 'Failed to update story',
|
||||
title: t('stories.toast.updateFailed'),
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
@@ -168,7 +170,7 @@ export function StoryList() {
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: 'Failed to delete story',
|
||||
title: t('stories.toast.deleteFailed'),
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
@@ -179,7 +181,7 @@ export function StoryList() {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-muted-foreground">Loading stories...</div>
|
||||
<div className="text-muted-foreground">{t('stories.loading')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -195,10 +197,10 @@ export function StoryList() {
|
||||
{/* Fixed Header */}
|
||||
<div className="absolute top-0 left-0 right-0 z-20">
|
||||
<div className="flex items-center justify-between mb-4 px-1">
|
||||
<h2 className="text-2xl font-bold">Stories</h2>
|
||||
<h2 className="text-2xl font-bold">{t('stories.title')}</h2>
|
||||
<Button onClick={() => setCreateDialogOpen(true)} size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New Story
|
||||
{t('stories.newStory')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -211,8 +213,8 @@ export function StoryList() {
|
||||
{storyList.length === 0 ? (
|
||||
<div className="text-center py-12 px-5 border-2 border-dashed border-muted rounded-2xl text-muted-foreground">
|
||||
<BookOpen className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p className="text-sm">No stories yet</p>
|
||||
<p className="text-xs mt-2">Create your first story to get started</p>
|
||||
<p className="text-sm">{t('stories.empty.title')}</p>
|
||||
<p className="text-xs mt-2">{t('stories.empty.hint')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
@@ -225,7 +227,11 @@ export function StoryList() {
|
||||
'px-5 py-3 rounded-lg transition-colors group flex items-center cursor-pointer',
|
||||
selectedStoryId === story.id ? 'bg-muted' : 'hover:bg-muted/50',
|
||||
)}
|
||||
aria-label={`Story ${story.name}, ${story.item_count} ${story.item_count === 1 ? 'item' : 'items'}, ${formatDate(story.updated_at)}`}
|
||||
aria-label={t('stories.row.ariaLabel', {
|
||||
name: story.name,
|
||||
count: story.item_count,
|
||||
updated: formatDate(story.updated_at),
|
||||
})}
|
||||
aria-pressed={selectedStoryId === story.id}
|
||||
onClick={() => setSelectedStoryId(story.id)}
|
||||
onKeyDown={(e) => {
|
||||
@@ -240,9 +246,7 @@ export function StoryList() {
|
||||
<div className="flex-1 min-w-0 text-left overflow-hidden">
|
||||
<h3 className="text-sm font-medium truncate">{story.name}</h3>
|
||||
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{story.item_count} {story.item_count === 1 ? 'item' : 'items'}
|
||||
</span>
|
||||
<span>{t('stories.row.itemCount', { count: story.item_count })}</span>
|
||||
<span>·</span>
|
||||
<span>{formatDate(story.updated_at)}</span>
|
||||
</div>
|
||||
@@ -254,7 +258,7 @@ export function StoryList() {
|
||||
size="icon"
|
||||
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={`Actions for ${story.name}`}
|
||||
aria-label={t('stories.row.actionsLabel', { name: story.name })}
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -262,14 +266,14 @@ export function StoryList() {
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleEditClick(story)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
{t('common.edit')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDeleteClick(story.id)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
{t('common.delete')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -284,17 +288,15 @@ export function StoryList() {
|
||||
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Story</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new story to organize your voice generations into conversations.
|
||||
</DialogDescription>
|
||||
<DialogTitle>{t('stories.createDialog.title')}</DialogTitle>
|
||||
<DialogDescription>{t('stories.createDialog.description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="story-name">Name</Label>
|
||||
<Label htmlFor="story-name">{t('stories.fields.name')}</Label>
|
||||
<Input
|
||||
id="story-name"
|
||||
placeholder="My Story"
|
||||
placeholder={t('stories.fields.namePlaceholder')}
|
||||
value={newStoryName}
|
||||
onChange={(e) => setNewStoryName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
@@ -305,10 +307,10 @@ export function StoryList() {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="story-description">Description (optional)</Label>
|
||||
<Label htmlFor="story-description">{t('stories.fields.descriptionLabel')}</Label>
|
||||
<Textarea
|
||||
id="story-description"
|
||||
placeholder="A conversation between..."
|
||||
placeholder={t('stories.fields.descriptionPlaceholder')}
|
||||
value={newStoryDescription}
|
||||
onChange={(e) => setNewStoryDescription(e.target.value)}
|
||||
rows={3}
|
||||
@@ -317,28 +319,29 @@ export function StoryList() {
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleCreateStory} disabled={createStory.isPending}>
|
||||
{createStory.isPending ? 'Creating...' : 'Create'}
|
||||
{createStory.isPending
|
||||
? t('stories.createDialog.creating')
|
||||
: t('stories.createDialog.action')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Edit Story Dialog */}
|
||||
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Story</DialogTitle>
|
||||
<DialogDescription>Update the story name and description.</DialogDescription>
|
||||
<DialogTitle>{t('stories.editDialog.title')}</DialogTitle>
|
||||
<DialogDescription>{t('stories.editDialog.description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-story-name">Name</Label>
|
||||
<Label htmlFor="edit-story-name">{t('stories.fields.name')}</Label>
|
||||
<Input
|
||||
id="edit-story-name"
|
||||
placeholder="My Story"
|
||||
placeholder={t('stories.fields.namePlaceholder')}
|
||||
value={newStoryName}
|
||||
onChange={(e) => setNewStoryName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
@@ -349,10 +352,10 @@ export function StoryList() {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-story-description">Description (optional)</Label>
|
||||
<Label htmlFor="edit-story-description">{t('stories.fields.descriptionLabel')}</Label>
|
||||
<Textarea
|
||||
id="edit-story-description"
|
||||
placeholder="A conversation between..."
|
||||
placeholder={t('stories.fields.descriptionPlaceholder')}
|
||||
value={newStoryDescription}
|
||||
onChange={(e) => setNewStoryDescription(e.target.value)}
|
||||
rows={3}
|
||||
@@ -361,34 +364,30 @@ export function StoryList() {
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditDialogOpen(false)}>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleUpdateStory} disabled={updateStory.isPending}>
|
||||
{updateStory.isPending ? 'Saving...' : 'Save'}
|
||||
{updateStory.isPending ? t('stories.editDialog.saving') : t('common.save')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Story Confirmation Dialog */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete the story and all its items. This action cannot be
|
||||
undone.
|
||||
</AlertDialogDescription>
|
||||
<AlertDialogTitle>{t('stories.deleteDialog.title')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{t('stories.deleteDialog.description')}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction asChild>
|
||||
<Button
|
||||
onClick={handleDeleteConfirm}
|
||||
disabled={deleteStory.isPending}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{deleteStory.isPending ? 'Deleting...' : 'Delete'}
|
||||
{deleteStory.isPending ? t('stories.deleteDialog.deleting') : t('common.delete')}
|
||||
</Button>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Mic, Pause, Play, Square } from 'lucide-react';
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Visualizer } from 'react-sound-visualizer';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FormControl, FormItem, FormMessage } from '@/components/ui/form';
|
||||
@@ -48,6 +49,7 @@ export function AudioSampleRecording({
|
||||
isTranscribing = false,
|
||||
showWaveform = true,
|
||||
}: AudioSampleRecordingProps) {
|
||||
const { t } = useTranslation();
|
||||
const [audioStream, setAudioStream] = useState<MediaStream | null>(null);
|
||||
|
||||
// Request microphone access when component mounts
|
||||
@@ -90,10 +92,10 @@ export function AudioSampleRecording({
|
||||
className="relative z-10 flex items-center gap-2"
|
||||
>
|
||||
<Mic className="h-5 w-5" />
|
||||
Start Recording
|
||||
{t('audioSample.startRecording')}
|
||||
</Button>
|
||||
<p className="relative z-10 text-sm text-muted-foreground text-center">
|
||||
Click to start recording. Maximum duration: 30 seconds.
|
||||
{t('audioSample.recordHint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -115,10 +117,10 @@ export function AudioSampleRecording({
|
||||
className="relative z-10 flex items-center gap-2 bg-accent text-accent-foreground hover:bg-accent/90"
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
Stop Recording
|
||||
{t('audioSample.stopRecording')}
|
||||
</Button>
|
||||
<p className="relative z-10 text-sm text-muted-foreground text-center">
|
||||
{formatAudioDuration(30 - duration)} remaining
|
||||
{t('audioSample.remaining', { time: formatAudioDuration(30 - duration) })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -127,16 +129,18 @@ export function AudioSampleRecording({
|
||||
<div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-primary rounded-lg bg-primary/5 min-h-[180px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Mic className="h-5 w-5 text-primary" />
|
||||
<span className="font-medium">Recording complete</span>
|
||||
<span className="font-medium">{t('audioSample.recordingComplete')}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground text-center">File: {file.name}</p>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
{t('audioSample.fileLabel', { name: file.name })}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={onPlayPause}
|
||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||
aria-label={isPlaying ? t('audioSample.pause') : t('audioSample.play')}
|
||||
>
|
||||
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
</Button>
|
||||
@@ -148,7 +152,7 @@ export function AudioSampleRecording({
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Mic className="h-4 w-4" />
|
||||
{isTranscribing ? 'Transcribing...' : 'Transcribe'}
|
||||
{isTranscribing ? t('audioSample.transcribing') : t('audioSample.transcribe')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -156,7 +160,7 @@ export function AudioSampleRecording({
|
||||
onClick={onCancel}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
Record Again
|
||||
{t('audioSample.recordAgain')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Mic, Monitor, Pause, Play, Square } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FormControl, FormItem, FormMessage } from '@/components/ui/form';
|
||||
import { formatAudioDuration } from '@/lib/utils/audio';
|
||||
@@ -28,6 +29,7 @@ export function AudioSampleSystem({
|
||||
isPlaying,
|
||||
isTranscribing = false,
|
||||
}: AudioSampleSystemProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
@@ -36,10 +38,10 @@ export function AudioSampleSystem({
|
||||
<div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-dashed rounded-lg min-h-[180px]">
|
||||
<Button type="button" onClick={onStart} size="lg" className="flex items-center gap-2">
|
||||
<Monitor className="h-5 w-5" />
|
||||
Start Capture
|
||||
{t('audioSample.startCapture')}
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Capture audio from your system. Maximum duration: 30 seconds.
|
||||
{t('audioSample.systemHint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -61,10 +63,10 @@ export function AudioSampleSystem({
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
Stop Capture
|
||||
{t('audioSample.stopCapture')}
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
{formatAudioDuration(30 - duration)} remaining
|
||||
{t('audioSample.remaining', { time: formatAudioDuration(30 - duration) })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -73,16 +75,18 @@ export function AudioSampleSystem({
|
||||
<div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-primary rounded-lg bg-primary/5 min-h-[180px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Monitor className="h-5 w-5 text-primary" />
|
||||
<span className="font-medium">Capture complete</span>
|
||||
<span className="font-medium">{t('audioSample.captureComplete')}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground text-center">File: {file.name}</p>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
{t('audioSample.fileLabel', { name: file.name })}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={onPlayPause}
|
||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||
aria-label={isPlaying ? t('audioSample.pause') : t('audioSample.play')}
|
||||
>
|
||||
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
</Button>
|
||||
@@ -94,7 +98,7 @@ export function AudioSampleSystem({
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Mic className="h-4 w-4" />
|
||||
{isTranscribing ? 'Transcribing...' : 'Transcribe'}
|
||||
{isTranscribing ? t('audioSample.transcribing') : t('audioSample.transcribe')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -102,7 +106,7 @@ export function AudioSampleSystem({
|
||||
onClick={onCancel}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
Capture Again
|
||||
{t('audioSample.captureAgain')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Mic, Pause, Play, Upload } from 'lucide-react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FormControl, FormItem, FormMessage } from '@/components/ui/form';
|
||||
|
||||
@@ -26,6 +27,7 @@ export function AudioSampleUpload({
|
||||
isDisabled = false,
|
||||
fieldName,
|
||||
}: AudioSampleUploadProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -90,19 +92,21 @@ export function AudioSampleUpload({
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Upload className="h-5 w-5" />
|
||||
Choose File
|
||||
{t('audioSample.chooseFile')}
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Click to choose a file or drag and drop. Maximum duration: 30 seconds.
|
||||
{t('audioSample.uploadHint')}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<Upload className="h-5 w-5 text-primary" />
|
||||
<span className="font-medium">File uploaded</span>
|
||||
<span className="font-medium">{t('audioSample.fileUploaded')}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground text-center">File: {file.name}</p>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
{t('audioSample.fileLabel', { name: file.name })}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
@@ -110,7 +114,7 @@ export function AudioSampleUpload({
|
||||
variant="outline"
|
||||
onClick={onPlayPause}
|
||||
disabled={isValidating}
|
||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||
aria-label={isPlaying ? t('audioSample.pause') : t('audioSample.play')}
|
||||
>
|
||||
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
</Button>
|
||||
@@ -122,7 +126,7 @@ export function AudioSampleUpload({
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Mic className="h-4 w-4" />
|
||||
{isTranscribing ? 'Transcribing...' : 'Transcribe'}
|
||||
{isTranscribing ? t('audioSample.transcribing') : t('audioSample.transcribe')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -134,7 +138,7 @@ export function AudioSampleUpload({
|
||||
}
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
{t('audioSample.remove')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Download, Edit, Sparkles, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -29,6 +30,7 @@ interface ProfileCardProps {
|
||||
}
|
||||
|
||||
export function ProfileCard({ profile, disabled }: ProfileCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
|
||||
const deleteProfile = useDeleteProfile();
|
||||
@@ -41,7 +43,6 @@ export function ProfileCard({ profile, disabled }: ProfileCardProps) {
|
||||
const isSelected = selectedProfileId === profile.id;
|
||||
|
||||
const handleSelect = () => {
|
||||
// If disabled but already selected, bounce the selection to re-trigger engine auto-switch
|
||||
if (disabled && isSelected) {
|
||||
setSelectedProfileId(null);
|
||||
setTimeout(() => setSelectedProfileId(profile.id), 0);
|
||||
@@ -79,9 +80,10 @@ export function ProfileCard({ profile, disabled }: ProfileCardProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const selectLabel = isSelected
|
||||
? `${profile.name}, ${profile.language}. Selected as voice for generation.`
|
||||
: `${profile.name}, ${profile.language}. Select as voice for generation.`;
|
||||
const selectLabel = t(
|
||||
isSelected ? 'profiles.card.selectLabelSelected' : 'profiles.card.selectLabel',
|
||||
{ name: profile.name, language: profile.language },
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -105,7 +107,7 @@ export function ProfileCard({ profile, disabled }: ProfileCardProps) {
|
||||
</CardHeader>
|
||||
<CardContent className="p-3 pt-0 flex flex-col flex-1">
|
||||
<p className="text-xs text-muted-foreground mb-1.5 line-clamp-2 leading-relaxed">
|
||||
{profile.description || 'No description'}
|
||||
{profile.description || t('profiles.card.noDescription')}
|
||||
</p>
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
<Badge variant="outline" className="text-xs h-5 px-1.5 text-muted-foreground">
|
||||
@@ -118,7 +120,7 @@ export function ProfileCard({ profile, disabled }: ProfileCardProps) {
|
||||
)}
|
||||
{profile.voice_type === 'designed' && (
|
||||
<Badge variant="secondary" className="text-xs h-5 px-1.5">
|
||||
designed
|
||||
{t('profiles.card.designed')}
|
||||
</Badge>
|
||||
)}
|
||||
{profile.effects_chain && profile.effects_chain.length > 0 && (
|
||||
@@ -130,7 +132,7 @@ export function ProfileCard({ profile, disabled }: ProfileCardProps) {
|
||||
icon={Download}
|
||||
onClick={handleExport}
|
||||
disabled={exportProfile.isPending}
|
||||
aria-label="Export profile"
|
||||
aria-label={t('profiles.card.export')}
|
||||
/>
|
||||
<CircleButton
|
||||
icon={Edit}
|
||||
@@ -138,13 +140,13 @@ export function ProfileCard({ profile, disabled }: ProfileCardProps) {
|
||||
e.stopPropagation();
|
||||
handleEdit();
|
||||
}}
|
||||
aria-label="Edit profile"
|
||||
aria-label={t('profiles.card.edit')}
|
||||
/>
|
||||
<CircleButton
|
||||
icon={Trash2}
|
||||
onClick={handleDeleteClick}
|
||||
disabled={deleteProfile.isPending}
|
||||
aria-label="Delete profile"
|
||||
aria-label={t('profiles.card.delete')}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -153,21 +155,21 @@ export function ProfileCard({ profile, disabled }: ProfileCardProps) {
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Profile</DialogTitle>
|
||||
<DialogTitle>{t('profiles.deleteDialog.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete "{profile.name}"? This action cannot be undone.
|
||||
{t('profiles.deleteDialog.body', { name: profile.name })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteConfirm}
|
||||
disabled={deleteProfile.isPending}
|
||||
>
|
||||
{deleteProfile.isPending ? 'Deleting...' : 'Delete'}
|
||||
{deleteProfile.isPending ? t('profiles.deleteDialog.deleting') : t('common.delete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { Edit2, Mic, Monitor, Music, Upload, X } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import * as z from 'zod';
|
||||
import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -71,30 +72,38 @@ const DEFAULT_ENGINE_OPTIONS = [
|
||||
{ value: 'kokoro', label: 'Kokoro 82M' },
|
||||
] as const;
|
||||
|
||||
const baseProfileSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required').max(100),
|
||||
description: z.string().max(500).optional(),
|
||||
language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]),
|
||||
sampleFile: z.instanceof(File).optional(),
|
||||
referenceText: z.string().max(1000).optional(),
|
||||
avatarFile: z.instanceof(File).optional(),
|
||||
});
|
||||
function makeProfileSchema(t: (key: string) => string) {
|
||||
const baseProfileSchema = z.object({
|
||||
name: z.string().min(1, t('profileForm.validation.nameRequired')).max(100),
|
||||
description: z.string().max(500).optional(),
|
||||
language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]),
|
||||
sampleFile: z.instanceof(File).optional(),
|
||||
referenceText: z.string().max(1000).optional(),
|
||||
avatarFile: z.instanceof(File).optional(),
|
||||
});
|
||||
|
||||
const profileSchema = baseProfileSchema.refine(
|
||||
(data) => {
|
||||
// If sample file is provided, reference text is required
|
||||
if (data.sampleFile && (!data.referenceText || data.referenceText.trim().length === 0)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: 'Reference text is required when adding a sample',
|
||||
path: ['referenceText'],
|
||||
},
|
||||
);
|
||||
return baseProfileSchema.refine(
|
||||
(data) => {
|
||||
if (data.sampleFile && (!data.referenceText || data.referenceText.trim().length === 0)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: t('profileForm.validation.referenceRequired'),
|
||||
path: ['referenceText'],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
type ProfileFormValues = z.infer<typeof profileSchema>;
|
||||
type ProfileFormValues = {
|
||||
name: string;
|
||||
description?: string;
|
||||
language: LanguageCode;
|
||||
sampleFile?: File;
|
||||
referenceText?: string;
|
||||
avatarFile?: File;
|
||||
};
|
||||
|
||||
// Helper to convert File to base64
|
||||
async function fileToBase64(file: File): Promise<string> {
|
||||
@@ -119,6 +128,7 @@ function base64ToFile(base64: string, fileName: string, fileType: string): File
|
||||
}
|
||||
|
||||
export function ProfileForm() {
|
||||
const { t } = useTranslation();
|
||||
const platform = usePlatform();
|
||||
const open = useUIStore((state) => state.profileDialogOpen);
|
||||
const setOpen = useUIStore((state) => state.setProfileDialogOpen);
|
||||
@@ -151,7 +161,7 @@ export function ProfileForm() {
|
||||
const [defaultEngine, setDefaultEngine] = useState<string>('');
|
||||
|
||||
const form = useForm<ProfileFormValues>({
|
||||
resolver: zodResolver(profileSchema),
|
||||
resolver: zodResolver(makeProfileSchema(t)),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
description: '',
|
||||
@@ -175,7 +185,10 @@ export function ProfileForm() {
|
||||
if (duration > MAX_AUDIO_DURATION_SECONDS) {
|
||||
form.setError('sampleFile', {
|
||||
type: 'manual',
|
||||
message: `Audio is too long (${formatAudioDuration(duration)}). Maximum duration is ${formatAudioDuration(MAX_AUDIO_DURATION_SECONDS)}.`,
|
||||
message: t('profileForm.validation.audioTooLong', {
|
||||
duration: formatAudioDuration(duration),
|
||||
max: formatAudioDuration(MAX_AUDIO_DURATION_SECONDS),
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
form.clearErrors('sampleFile');
|
||||
@@ -184,14 +197,13 @@ export function ProfileForm() {
|
||||
.catch((error) => {
|
||||
console.error('Failed to get audio duration:', error);
|
||||
setAudioDuration(null);
|
||||
// For recordings, we auto-stop at max duration, so we can skip validation errors
|
||||
const isRecordedFile =
|
||||
selectedFile.name.startsWith('recording-') ||
|
||||
selectedFile.name.startsWith('system-audio-');
|
||||
if (!isRecordedFile) {
|
||||
form.setError('sampleFile', {
|
||||
type: 'manual',
|
||||
message: 'Failed to validate audio file. Please try a different file.',
|
||||
message: t('profileForm.validation.audioFailed'),
|
||||
});
|
||||
} else {
|
||||
// Clear any existing errors for recorded files
|
||||
@@ -205,7 +217,7 @@ export function ProfileForm() {
|
||||
setAudioDuration(null);
|
||||
form.clearErrors('sampleFile');
|
||||
}
|
||||
}, [selectedFile, form]);
|
||||
}, [selectedFile, form, t]);
|
||||
|
||||
const {
|
||||
isRecording,
|
||||
@@ -226,8 +238,8 @@ export function ProfileForm() {
|
||||
}
|
||||
form.setValue('sampleFile', file, { shouldValidate: true });
|
||||
toast({
|
||||
title: 'Recording complete',
|
||||
description: 'Audio has been recorded successfully.',
|
||||
title: t('profileForm.toast.recordingComplete'),
|
||||
description: t('profileForm.toast.recordingCompleteDescription'),
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -252,8 +264,8 @@ export function ProfileForm() {
|
||||
}
|
||||
form.setValue('sampleFile', file, { shouldValidate: true });
|
||||
toast({
|
||||
title: 'System audio captured',
|
||||
description: 'Audio has been captured successfully.',
|
||||
title: t('profileForm.toast.systemAudioCaptured'),
|
||||
description: t('profileForm.toast.systemAudioCapturedDescription'),
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -282,23 +294,22 @@ export function ProfileForm() {
|
||||
useEffect(() => {
|
||||
if (recordingError) {
|
||||
toast({
|
||||
title: 'Recording error',
|
||||
title: t('profileForm.toast.recordingError'),
|
||||
description: recordingError,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}, [recordingError, toast]);
|
||||
}, [recordingError, toast, t]);
|
||||
|
||||
// Show system audio recording errors
|
||||
useEffect(() => {
|
||||
if (systemRecordingError) {
|
||||
toast({
|
||||
title: 'System audio capture error',
|
||||
title: t('profileForm.toast.systemAudioError'),
|
||||
description: systemRecordingError,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}, [systemRecordingError, toast]);
|
||||
}, [systemRecordingError, toast, t]);
|
||||
|
||||
// Handle avatar preview
|
||||
useEffect(() => {
|
||||
@@ -388,8 +399,8 @@ export function ProfileForm() {
|
||||
const file = form.getValues('sampleFile');
|
||||
if (!file) {
|
||||
toast({
|
||||
title: 'No file selected',
|
||||
description: 'Please select an audio file first.',
|
||||
title: t('profileForm.toast.noFile'),
|
||||
description: t('profileForm.toast.noFileDescription'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
@@ -402,8 +413,9 @@ export function ProfileForm() {
|
||||
form.setValue('referenceText', result.text, { shouldValidate: true });
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Transcription failed',
|
||||
description: error instanceof Error ? error.message : 'Failed to transcribe audio',
|
||||
title: t('profileForm.toast.transcribeFailed'),
|
||||
description:
|
||||
error instanceof Error ? error.message : t('profileForm.toast.transcribeFailedFallback'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
@@ -429,16 +441,16 @@ export function ProfileForm() {
|
||||
if (file) {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast({
|
||||
title: 'Invalid file type',
|
||||
description: 'Please select an image file (PNG, JPG, or WebP)',
|
||||
title: t('profileForm.toast.invalidFile'),
|
||||
description: t('profileForm.toast.invalidImageFormat'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast({
|
||||
title: 'File too large',
|
||||
description: 'Image must be less than 5MB',
|
||||
title: t('profileForm.toast.fileTooLarge'),
|
||||
description: t('profileForm.toast.imageTooLargeDescription'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
@@ -452,13 +464,13 @@ export function ProfileForm() {
|
||||
try {
|
||||
await deleteAvatar.mutateAsync(editingProfileId);
|
||||
toast({
|
||||
title: 'Avatar removed',
|
||||
description: 'Avatar image has been removed successfully.',
|
||||
title: t('profileForm.toast.avatarRemoved'),
|
||||
description: t('profileForm.toast.avatarRemovedDescription'),
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to remove avatar',
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
title: t('profileForm.toast.avatarRemoveFailed'),
|
||||
description: error instanceof Error ? error.message : t('common.unknownError'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
@@ -493,9 +505,11 @@ export function ProfileForm() {
|
||||
});
|
||||
} catch (avatarError) {
|
||||
toast({
|
||||
title: 'Avatar upload failed',
|
||||
title: t('profileForm.toast.avatarUploadFailed'),
|
||||
description:
|
||||
avatarError instanceof Error ? avatarError.message : 'Failed to upload avatar',
|
||||
avatarError instanceof Error
|
||||
? avatarError.message
|
||||
: t('profileForm.toast.avatarUploadFailedFallback'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
@@ -510,9 +524,11 @@ export function ProfileForm() {
|
||||
);
|
||||
} catch (fxError) {
|
||||
toast({
|
||||
title: 'Effects update failed',
|
||||
title: t('profileForm.toast.effectsUpdateFailed'),
|
||||
description:
|
||||
fxError instanceof Error ? fxError.message : 'Failed to save effects chain',
|
||||
fxError instanceof Error
|
||||
? fxError.message
|
||||
: t('profileForm.toast.effectsUpdateFailedFallback'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
@@ -520,15 +536,15 @@ export function ProfileForm() {
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Voice updated',
|
||||
description: `"${data.name}" has been updated successfully.`,
|
||||
title: t('profileForm.toast.voiceUpdated'),
|
||||
description: t('profileForm.toast.voiceUpdatedDescription', { name: data.name }),
|
||||
});
|
||||
} else if (voiceSource === 'builtin') {
|
||||
// Creating preset profile from built-in voice
|
||||
if (!selectedPresetVoiceId) {
|
||||
toast({
|
||||
title: 'No voice selected',
|
||||
description: 'Please select a built-in voice.',
|
||||
title: t('profileForm.toast.noVoiceSelected'),
|
||||
description: t('profileForm.toast.noVoiceSelectedDescription'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
@@ -553,17 +569,19 @@ export function ProfileForm() {
|
||||
});
|
||||
} catch (avatarError) {
|
||||
toast({
|
||||
title: 'Avatar upload failed',
|
||||
title: t('profileForm.toast.avatarUploadFailed'),
|
||||
description:
|
||||
avatarError instanceof Error ? avatarError.message : 'Failed to upload avatar',
|
||||
avatarError instanceof Error
|
||||
? avatarError.message
|
||||
: t('profileForm.toast.avatarUploadFailedFallback'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Profile created',
|
||||
description: `"${data.name}" has been created with a built-in voice.`,
|
||||
title: t('profileForm.toast.profileCreated'),
|
||||
description: t('profileForm.toast.profileCreatedBuiltin', { name: data.name }),
|
||||
});
|
||||
} else {
|
||||
// Creating cloned profile: require sample file and reference text
|
||||
@@ -573,11 +591,11 @@ export function ProfileForm() {
|
||||
if (!sampleFile) {
|
||||
form.setError('sampleFile', {
|
||||
type: 'manual',
|
||||
message: 'Audio sample is required',
|
||||
message: t('profileForm.validation.sampleRequired'),
|
||||
});
|
||||
toast({
|
||||
title: 'Audio sample required',
|
||||
description: 'Please provide an audio sample to create the voice profile.',
|
||||
title: t('profileForm.toast.sampleRequired'),
|
||||
description: t('profileForm.toast.sampleRequiredDescription'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
@@ -586,42 +604,48 @@ export function ProfileForm() {
|
||||
if (!referenceText || referenceText.trim().length === 0) {
|
||||
form.setError('referenceText', {
|
||||
type: 'manual',
|
||||
message: 'Reference text is required',
|
||||
message: t('profileForm.validation.referenceTextRequired'),
|
||||
});
|
||||
toast({
|
||||
title: 'Reference text required',
|
||||
description: 'Please provide the reference text for the audio sample.',
|
||||
title: t('profileForm.toast.referenceTextRequired'),
|
||||
description: t('profileForm.toast.referenceTextRequiredDescription'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate audio duration before creating profile
|
||||
try {
|
||||
const duration = await getAudioDuration(sampleFile);
|
||||
if (duration > MAX_AUDIO_DURATION_SECONDS) {
|
||||
form.setError('sampleFile', {
|
||||
type: 'manual',
|
||||
message: `Audio is too long (${formatAudioDuration(duration)}). Maximum duration is ${formatAudioDuration(MAX_AUDIO_DURATION_SECONDS)}.`,
|
||||
message: t('profileForm.validation.audioTooLong', {
|
||||
duration: formatAudioDuration(duration),
|
||||
max: formatAudioDuration(MAX_AUDIO_DURATION_SECONDS),
|
||||
}),
|
||||
});
|
||||
toast({
|
||||
title: 'Invalid audio file',
|
||||
description: `Audio duration is ${formatAudioDuration(duration)}, but maximum is ${formatAudioDuration(MAX_AUDIO_DURATION_SECONDS)}.`,
|
||||
title: t('profileForm.toast.invalidAudio'),
|
||||
description: t('profileForm.toast.invalidAudioDescription', {
|
||||
duration: formatAudioDuration(duration),
|
||||
max: formatAudioDuration(MAX_AUDIO_DURATION_SECONDS),
|
||||
}),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return; // Prevent form submission
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
form.setError('sampleFile', {
|
||||
type: 'manual',
|
||||
message: 'Failed to validate audio file. Please try a different file.',
|
||||
message: t('profileForm.validation.audioFailed'),
|
||||
});
|
||||
toast({
|
||||
title: 'Validation error',
|
||||
description: error instanceof Error ? error.message : 'Failed to validate audio file',
|
||||
title: t('profileForm.toast.validationError'),
|
||||
description:
|
||||
error instanceof Error ? error.message : t('profileForm.validation.audioFailed'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return; // Prevent form submission
|
||||
return;
|
||||
}
|
||||
|
||||
// Creating: create profile, then add sample
|
||||
@@ -670,8 +694,8 @@ export function ProfileForm() {
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Profile created',
|
||||
description: `"${data.name}" has been created with a sample.`,
|
||||
title: t('profileForm.toast.profileCreated'),
|
||||
description: t('profileForm.toast.profileCreatedSample', { name: data.name }),
|
||||
});
|
||||
} catch (sampleError) {
|
||||
let rollbackSucceeded = false;
|
||||
@@ -680,23 +704,26 @@ export function ProfileForm() {
|
||||
rollbackSucceeded = true;
|
||||
} catch (rollbackError) {
|
||||
toast({
|
||||
title: 'Rollback failed',
|
||||
title: t('profileForm.toast.rollbackFailed'),
|
||||
description:
|
||||
rollbackError instanceof Error
|
||||
? rollbackError.message
|
||||
: 'Created profile could not be removed after sample upload failure.',
|
||||
: t('profileForm.toast.rollbackFailedDescription'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
||||
const rollbackSuffix = rollbackSucceeded
|
||||
? ` ${t('profileForm.toast.profileRolledBack')}`
|
||||
: '';
|
||||
toast({
|
||||
title: 'Failed to add sample',
|
||||
title: t('profileForm.toast.sampleFailed'),
|
||||
description:
|
||||
sampleError instanceof Error
|
||||
? `${sampleError.message}${rollbackSucceeded ? ' The profile was rolled back.' : ''}`
|
||||
? `${sampleError.message}${rollbackSuffix}`
|
||||
: rollbackSucceeded
|
||||
? 'Failed to add sample. The profile was rolled back.'
|
||||
: 'Failed to add sample.',
|
||||
? t('profileForm.toast.sampleFailedRolledBack')
|
||||
: t('profileForm.toast.sampleFailedDescription'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
@@ -710,8 +737,8 @@ export function ProfileForm() {
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error instanceof Error ? error.message : 'Failed to save profile',
|
||||
title: t('common.error'),
|
||||
description: error instanceof Error ? error.message : t('profileForm.toast.saveFailed'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
@@ -768,16 +795,18 @@ export function ProfileForm() {
|
||||
<div className="max-w-5xl h-[85vh] mx-auto my-auto w-full flex flex-col overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl">
|
||||
{editingProfileId ? 'Edit Voice' : 'Create Voice'}
|
||||
{editingProfileId ? t('profileForm.editTitle') : t('profileForm.createTitle')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingProfileId
|
||||
? 'Update your voice profile details and manage samples.'
|
||||
: 'Create a new voice profile from an audio sample or a built-in voice.'}
|
||||
? t('profileForm.editDescription')
|
||||
: t('profileForm.createDescription')}
|
||||
</DialogDescription>
|
||||
{isCreating && profileFormDraft && (
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<span className="text-xs text-muted-foreground">Draft restored</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('profileForm.draftRestored')}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -796,7 +825,7 @@ export function ProfileForm() {
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3 mr-1" />
|
||||
Discard
|
||||
{t('profileForm.discard')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -822,7 +851,7 @@ export function ProfileForm() {
|
||||
}`}
|
||||
>
|
||||
<Mic className="h-3.5 w-3.5" />
|
||||
Clone from audio
|
||||
{t('profileForm.source.clone')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -834,20 +863,17 @@ export function ProfileForm() {
|
||||
}`}
|
||||
>
|
||||
<Music className="h-3.5 w-3.5" />
|
||||
Built-in voice
|
||||
{t('profileForm.source.builtin')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{voiceSource === 'builtin' ? (
|
||||
<div className="space-y-4">
|
||||
<FormDescription>
|
||||
Choose a pre-built voice. These don't require an audio sample.
|
||||
</FormDescription>
|
||||
<FormDescription>{t('profileForm.builtin.hint')}</FormDescription>
|
||||
|
||||
{/* Engine selector */}
|
||||
<FormItem>
|
||||
<FormLabel>Engine</FormLabel>
|
||||
<FormLabel>{t('profileForm.fields.engine')}</FormLabel>
|
||||
<Select
|
||||
value={selectedPresetEngine}
|
||||
onValueChange={setSelectedPresetEngine}
|
||||
@@ -866,7 +892,7 @@ export function ProfileForm() {
|
||||
|
||||
{/* Voice picker */}
|
||||
<FormItem>
|
||||
<FormLabel>Voice</FormLabel>
|
||||
<FormLabel>{t('profileForm.fields.voice')}</FormLabel>
|
||||
<div className="grid grid-cols-2 gap-1.5 max-h-[340px] overflow-y-auto pr-1">
|
||||
{presetVoices.map((voice: PresetVoice) => (
|
||||
<button
|
||||
@@ -921,16 +947,16 @@ export function ProfileForm() {
|
||||
>
|
||||
<TabsTrigger value="upload" className="flex items-center gap-2">
|
||||
<Upload className="h-4 w-4 shrink-0" />
|
||||
Upload
|
||||
{t('profileForm.sampleTabs.upload')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="record" className="flex items-center gap-2">
|
||||
<Mic className="h-4 w-4 shrink-0" />
|
||||
Record
|
||||
{t('profileForm.sampleTabs.record')}
|
||||
</TabsTrigger>
|
||||
{platform.metadata.isTauri && isSystemAudioSupported && (
|
||||
<TabsTrigger value="system" className="flex items-center gap-2">
|
||||
<Monitor className="h-4 w-4 shrink-0" />
|
||||
System Audio
|
||||
{t('profileForm.sampleTabs.system')}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
@@ -1008,10 +1034,10 @@ export function ProfileForm() {
|
||||
name="referenceText"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Reference Text</FormLabel>
|
||||
<FormLabel>{t('profileForm.fields.referenceText')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Enter the exact text spoken in the audio..."
|
||||
placeholder={t('profileForm.fields.referenceTextPlaceholder')}
|
||||
className="min-h-[100px]"
|
||||
{...field}
|
||||
/>
|
||||
@@ -1031,7 +1057,7 @@ export function ProfileForm() {
|
||||
<div className="space-y-4 pt-4">
|
||||
<div className="rounded-lg border border-border p-4 space-y-3">
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
Built-in Voice
|
||||
{t('profileForm.builtin.badge')}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-lg font-semibold">
|
||||
@@ -1060,8 +1086,7 @@ export function ProfileForm() {
|
||||
})()}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This profile uses a built-in voice. The voice cannot be changed after
|
||||
creation.
|
||||
{t('profileForm.builtin.note')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -1087,7 +1112,7 @@ export function ProfileForm() {
|
||||
{avatarPreview ? (
|
||||
<img
|
||||
src={avatarPreview}
|
||||
alt="Avatar preview"
|
||||
alt={t('profileForm.avatar.alt')}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
@@ -1131,9 +1156,9 @@ export function ProfileForm() {
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormLabel>{t('profileForm.fields.name')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Voice" {...field} />
|
||||
<Input placeholder={t('profileForm.fields.namePlaceholder')} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -1145,9 +1170,12 @@ export function ProfileForm() {
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description (Optional)</FormLabel>
|
||||
<FormLabel>{t('profileForm.fields.descriptionLabel')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea placeholder="Describe this voice..." {...field} />
|
||||
<Textarea
|
||||
placeholder={t('profileForm.fields.descriptionPlaceholder')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -1159,7 +1187,7 @@ export function ProfileForm() {
|
||||
name="language"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Language</FormLabel>
|
||||
<FormLabel>{t('profileForm.fields.language')}</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
@@ -1180,7 +1208,7 @@ export function ProfileForm() {
|
||||
/>
|
||||
|
||||
<FormItem>
|
||||
<FormLabel>Default Engine</FormLabel>
|
||||
<FormLabel>{t('profileForm.fields.defaultEngine')}</FormLabel>
|
||||
<Select
|
||||
value={defaultEngine || '_none'}
|
||||
onValueChange={(v) => {
|
||||
@@ -1192,11 +1220,13 @@ export function ProfileForm() {
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No preference" />
|
||||
<SelectValue placeholder={t('profileForm.fields.noPreference')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="_none">No preference</SelectItem>
|
||||
<SelectItem value="_none">
|
||||
{t('profileForm.fields.noPreference')}
|
||||
</SelectItem>
|
||||
{availableDefaultEngines.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
@@ -1205,15 +1235,15 @@ export function ProfileForm() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Auto-selects this engine when the profile is chosen.
|
||||
{t('profileForm.fields.defaultEngineHint')}
|
||||
</p>
|
||||
</FormItem>
|
||||
|
||||
{editingProfileId && (
|
||||
<div className="space-y-2">
|
||||
<FormLabel>Default Effects</FormLabel>
|
||||
<FormLabel>{t('profileForm.fields.defaultEffects')}</FormLabel>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Effects applied automatically to all new generations with this voice.
|
||||
{t('profileForm.fields.defaultEffectsHint')}
|
||||
</p>
|
||||
<EffectsChainEditor
|
||||
value={profileEffectsChain}
|
||||
@@ -1230,7 +1260,7 @@ export function ProfileForm() {
|
||||
|
||||
<div className="flex gap-2 justify-end mt-6 pt-4 border-t">
|
||||
<Button type="button" variant="outline" onClick={() => handleOpenChange(false)}>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -1239,10 +1269,10 @@ export function ProfileForm() {
|
||||
}
|
||||
>
|
||||
{createProfile.isPending || updateProfile.isPending || addSample.isPending
|
||||
? 'Saving...'
|
||||
? t('profileForm.actions.saving')
|
||||
: editingProfileId
|
||||
? 'Save Changes'
|
||||
: 'Create Profile'}
|
||||
? t('profileForm.actions.saveChanges')
|
||||
: t('profileForm.actions.createProfile')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Info, Mic, Sparkles } from 'lucide-react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { useProfiles } from '@/lib/hooks/useProfiles';
|
||||
@@ -11,6 +12,7 @@ import { ProfileForm } from './ProfileForm';
|
||||
const PRESET_ENGINES = new Set(['kokoro', 'qwen_custom_voice']);
|
||||
|
||||
export function ProfileList() {
|
||||
const { t } = useTranslation();
|
||||
const { data: profiles, isLoading, error } = useProfiles();
|
||||
const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
|
||||
const selectedEngine = useUIStore((state) => state.selectedEngine);
|
||||
@@ -45,7 +47,9 @@ export function ProfileList() {
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="text-destructive">Error loading profiles: {error.message}</div>
|
||||
<div className="text-destructive">
|
||||
{t('profiles.list.errorLoading', { message: error.message })}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -73,12 +77,10 @@ export function ProfileList() {
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Mic className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground mb-4">
|
||||
No voice profiles yet. Create your first profile to get started.
|
||||
</p>
|
||||
<p className="text-muted-foreground mb-4">{t('profiles.list.empty')}</p>
|
||||
<Button onClick={() => setDialogOpen(true)}>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
Create Voice
|
||||
{t('profiles.list.createVoice')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -99,7 +101,7 @@ export function ProfileList() {
|
||||
{hasUnsupported && (
|
||||
<div className="col-span-full flex items-center gap-2 text-xs text-muted-foreground py-2">
|
||||
<Info className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>Only supported voice profiles can be selected for the current model.</span>
|
||||
<span>{t('profiles.list.unsupportedNote')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Check, Edit, Pause, Play, Plus, Trash2, Volume2, X } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CircleButton } from '@/components/ui/circle-button';
|
||||
import {
|
||||
@@ -24,6 +25,7 @@ interface MiniSamplePlayerProps {
|
||||
}
|
||||
|
||||
function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) {
|
||||
const { t } = useTranslation();
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
@@ -102,7 +104,7 @@ function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) {
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={handlePlayPause}
|
||||
disabled={isLoading}
|
||||
aria-label={isPlaying ? 'Pause sample' : 'Play sample'}
|
||||
aria-label={isPlaying ? t('sampleList.player.pause') : t('sampleList.player.play')}
|
||||
>
|
||||
{isPlaying ? <Pause className="h-3.5 w-3.5" /> : <Play className="h-3.5 w-3.5 ml-0.5" />}
|
||||
</Button>
|
||||
@@ -114,8 +116,11 @@ function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) {
|
||||
max={100}
|
||||
step={0.1}
|
||||
className="flex-1"
|
||||
aria-label="Sample playback position"
|
||||
aria-valuetext={`${formatAudioDuration(currentTime)} of ${formatAudioDuration(duration)}`}
|
||||
aria-label={t('sampleList.player.position')}
|
||||
aria-valuetext={t('sampleList.player.positionValue', {
|
||||
current: formatAudioDuration(currentTime),
|
||||
total: formatAudioDuration(duration),
|
||||
})}
|
||||
/>
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground shrink-0 min-w-[70px]">
|
||||
<span className="font-mono">{formatAudioDuration(currentTime)}</span>
|
||||
@@ -130,8 +135,8 @@ function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) {
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={handleStop}
|
||||
title="Stop"
|
||||
aria-label="Stop playback"
|
||||
title={t('sampleList.player.stop')}
|
||||
aria-label={t('sampleList.player.stopAria')}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -145,6 +150,7 @@ interface SampleListProps {
|
||||
}
|
||||
|
||||
export function SampleList({ profileId }: SampleListProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: samples, isLoading } = useProfileSamples(profileId);
|
||||
const deleteSample = useDeleteSample();
|
||||
const updateSample = useUpdateSample();
|
||||
@@ -181,8 +187,8 @@ export function SampleList({ profileId }: SampleListProps) {
|
||||
const handleSaveEdit = async (sampleId: string) => {
|
||||
if (!editedText.trim()) {
|
||||
toast({
|
||||
title: 'Invalid text',
|
||||
description: 'Reference text cannot be empty.',
|
||||
title: t('sampleList.toast.invalidText'),
|
||||
description: t('sampleList.toast.invalidTextDescription'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
@@ -191,22 +197,23 @@ export function SampleList({ profileId }: SampleListProps) {
|
||||
try {
|
||||
await updateSample.mutateAsync({ sampleId, referenceText: editedText.trim() });
|
||||
toast({
|
||||
title: 'Sample updated',
|
||||
description: 'Reference text has been updated successfully.',
|
||||
title: t('sampleList.toast.updated'),
|
||||
description: t('sampleList.toast.updatedDescription'),
|
||||
});
|
||||
setEditingSampleId(null);
|
||||
setEditedText('');
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Update failed',
|
||||
description: error instanceof Error ? error.message : 'Failed to update sample',
|
||||
title: t('sampleList.toast.updateFailed'),
|
||||
description:
|
||||
error instanceof Error ? error.message : t('sampleList.toast.updateFailedFallback'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-sm text-muted-foreground">Loading samples...</div>;
|
||||
return <div className="text-sm text-muted-foreground">{t('sampleList.loading')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -214,10 +221,8 @@ export function SampleList({ profileId }: SampleListProps) {
|
||||
{samples && samples.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center border border-dashed rounded-lg">
|
||||
<Volume2 className="h-8 w-8 text-muted-foreground/50 mb-2" />
|
||||
<p className="text-sm text-muted-foreground">No samples yet</p>
|
||||
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||
Add your first audio sample to get started
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{t('sampleList.empty.title')}</p>
|
||||
<p className="text-xs text-muted-foreground/70 mt-1">{t('sampleList.empty.hint')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
@@ -237,13 +242,13 @@ export function SampleList({ profileId }: SampleListProps) {
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-2">
|
||||
<Edit className="h-3 w-3" />
|
||||
<span>Editing transcription</span>
|
||||
<span>{t('sampleList.editing')}</span>
|
||||
</div>
|
||||
<Textarea
|
||||
value={editedText}
|
||||
onChange={(e) => setEditedText(e.target.value)}
|
||||
className="min-h-[100px] text-sm resize-none"
|
||||
placeholder="Enter reference text..."
|
||||
placeholder={t('sampleList.placeholder')}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-2 pt-1">
|
||||
@@ -255,7 +260,7 @@ export function SampleList({ profileId }: SampleListProps) {
|
||||
disabled={updateSample.isPending}
|
||||
>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -264,7 +269,7 @@ export function SampleList({ profileId }: SampleListProps) {
|
||||
disabled={updateSample.isPending}
|
||||
>
|
||||
<Check className="h-4 w-4 mr-1" />
|
||||
{updateSample.isPending ? 'Saving...' : 'Save'}
|
||||
{updateSample.isPending ? t('sampleList.saving') : t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -283,12 +288,12 @@ export function SampleList({ profileId }: SampleListProps) {
|
||||
<div className="shrink-0 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<CircleButton
|
||||
icon={Edit}
|
||||
title="Edit transcription"
|
||||
title={t('sampleList.editTranscription')}
|
||||
onClick={() => handleStartEdit(sample.id, sample.reference_text)}
|
||||
/>
|
||||
<CircleButton
|
||||
icon={Trash2}
|
||||
title="Delete sample"
|
||||
title={t('sampleList.deleteSample')}
|
||||
onClick={() => handleDeleteClick(sample.id)}
|
||||
disabled={deleteSample.isPending}
|
||||
/>
|
||||
@@ -317,24 +322,18 @@ export function SampleList({ profileId }: SampleListProps) {
|
||||
onClick={() => setUploadOpen(true)}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Sample
|
||||
{t('sampleList.addSample')}
|
||||
</Button>
|
||||
|
||||
<p className="text-xs text-muted-foreground text-center px-2">
|
||||
Note: A single 30-second sample is the sweet spot. Quality may decrease with multiple
|
||||
samples. In a future update samples might be interchangeable and tagged for varying styles
|
||||
of the same voice.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground text-center px-2">{t('sampleList.note')}</p>
|
||||
|
||||
<SampleUpload profileId={profileId} open={uploadOpen} onOpenChange={setUploadOpen} />
|
||||
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Sample</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete this audio sample? This action cannot be undone.
|
||||
</DialogDescription>
|
||||
<DialogTitle>{t('sampleList.deleteDialog.title')}</DialogTitle>
|
||||
<DialogDescription>{t('sampleList.deleteDialog.description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
@@ -344,14 +343,14 @@ export function SampleList({ profileId }: SampleListProps) {
|
||||
setSampleToDelete(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteConfirm}
|
||||
disabled={deleteSample.isPending}
|
||||
>
|
||||
{deleteSample.isPending ? 'Deleting...' : 'Delete'}
|
||||
{deleteSample.isPending ? t('sampleList.deleteDialog.deleting') : t('common.delete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Edit2, Mic, X } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import * as z from 'zod';
|
||||
import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -38,19 +39,26 @@ import { cn } from '@/lib/utils/cn';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
|
||||
const profileSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required').max(100),
|
||||
description: z.string().max(500).optional(),
|
||||
language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]),
|
||||
});
|
||||
function makeProfileSchema(t: (key: string) => string) {
|
||||
return z.object({
|
||||
name: z.string().min(1, t('profileForm.validation.nameRequired')).max(100),
|
||||
description: z.string().max(500).optional(),
|
||||
language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]),
|
||||
});
|
||||
}
|
||||
|
||||
type ProfileFormValues = z.infer<typeof profileSchema>;
|
||||
type ProfileFormValues = {
|
||||
name: string;
|
||||
description?: string;
|
||||
language: LanguageCode;
|
||||
};
|
||||
|
||||
interface VoiceInspectorProps {
|
||||
profileId: string;
|
||||
}
|
||||
|
||||
export function VoiceInspector({ profileId }: VoiceInspectorProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: profile } = useProfile(profileId);
|
||||
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
||||
const isPlayerVisible = !!audioUrl;
|
||||
@@ -68,7 +76,7 @@ export function VoiceInspector({ profileId }: VoiceInspectorProps) {
|
||||
const [effectsDirty, setEffectsDirty] = useState(false);
|
||||
|
||||
const form = useForm<ProfileFormValues>({
|
||||
resolver: zodResolver(profileSchema),
|
||||
resolver: zodResolver(makeProfileSchema(t)),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
description: '',
|
||||
@@ -104,32 +112,31 @@ export function VoiceInspector({ profileId }: VoiceInspectorProps) {
|
||||
if (!file) return;
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast({
|
||||
title: 'Invalid file type',
|
||||
description: 'Please select PNG, JPG, or WebP',
|
||||
title: t('profileForm.toast.invalidFile'),
|
||||
description: t('voiceInspector.toast.invalidImageFormat'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast({
|
||||
title: 'File too large',
|
||||
description: 'Image must be less than 5MB',
|
||||
title: t('profileForm.toast.fileTooLarge'),
|
||||
description: t('profileForm.toast.imageTooLargeDescription'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Upload immediately
|
||||
uploadAvatar.mutate(
|
||||
{ profileId, file },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setAvatarPreview(URL.createObjectURL(file));
|
||||
toast({ title: 'Avatar updated' });
|
||||
toast({ title: t('voiceInspector.toast.avatarUpdated') });
|
||||
},
|
||||
onError: (err) => {
|
||||
toast({
|
||||
title: 'Avatar upload failed',
|
||||
description: err instanceof Error ? err.message : 'Unknown error',
|
||||
title: t('profileForm.toast.avatarUploadFailed'),
|
||||
description: err instanceof Error ? err.message : t('common.unknownError'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
@@ -141,11 +148,11 @@ export function VoiceInspector({ profileId }: VoiceInspectorProps) {
|
||||
if (profile?.avatar_path) {
|
||||
try {
|
||||
await deleteAvatar.mutateAsync(profileId);
|
||||
toast({ title: 'Avatar removed' });
|
||||
toast({ title: t('profileForm.toast.avatarRemoved') });
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Failed to remove avatar',
|
||||
description: err instanceof Error ? err.message : 'Unknown error',
|
||||
title: t('profileForm.toast.avatarRemoveFailed'),
|
||||
description: err instanceof Error ? err.message : t('common.unknownError'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
@@ -174,19 +181,25 @@ export function VoiceInspector({ profileId }: VoiceInspectorProps) {
|
||||
setEffectsDirty(false);
|
||||
} catch (fxError) {
|
||||
toast({
|
||||
title: 'Effects update failed',
|
||||
description: fxError instanceof Error ? fxError.message : 'Failed to save effects',
|
||||
title: t('profileForm.toast.effectsUpdateFailed'),
|
||||
description:
|
||||
fxError instanceof Error
|
||||
? fxError.message
|
||||
: t('profileForm.toast.effectsUpdateFailedFallback'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
toast({ title: 'Voice updated', description: `"${data.name}" saved.` });
|
||||
toast({
|
||||
title: t('profileForm.toast.voiceUpdated'),
|
||||
description: t('voiceInspector.toast.savedDescription', { name: data.name }),
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error instanceof Error ? error.message : 'Failed to save profile',
|
||||
title: t('common.error'),
|
||||
description: error instanceof Error ? error.message : t('profileForm.toast.saveFailed'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
@@ -195,7 +208,7 @@ export function VoiceInspector({ profileId }: VoiceInspectorProps) {
|
||||
if (!profile) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
||||
Loading...
|
||||
{t('voiceInspector.loading')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -256,9 +269,9 @@ export function VoiceInspector({ profileId }: VoiceInspectorProps) {
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormLabel>{t('profileForm.fields.name')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Voice" {...field} />
|
||||
<Input placeholder={t('profileForm.fields.namePlaceholder')} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -270,9 +283,13 @@ export function VoiceInspector({ profileId }: VoiceInspectorProps) {
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormLabel>{t('voiceInspector.fields.description')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea placeholder="Describe this voice..." rows={2} {...field} />
|
||||
<Textarea
|
||||
placeholder={t('profileForm.fields.descriptionPlaceholder')}
|
||||
rows={2}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -284,7 +301,7 @@ export function VoiceInspector({ profileId }: VoiceInspectorProps) {
|
||||
name="language"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Language</FormLabel>
|
||||
<FormLabel>{t('profileForm.fields.language')}</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
@@ -306,9 +323,9 @@ export function VoiceInspector({ profileId }: VoiceInspectorProps) {
|
||||
|
||||
{/* Effects */}
|
||||
<div className="space-y-2">
|
||||
<FormLabel>Default Effects</FormLabel>
|
||||
<FormLabel>{t('profileForm.fields.defaultEffects')}</FormLabel>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Applied automatically to new generations with this voice.
|
||||
{t('voiceInspector.defaultEffectsHint')}
|
||||
</p>
|
||||
<EffectsChainEditor
|
||||
value={effectsChain}
|
||||
@@ -323,7 +340,9 @@ export function VoiceInspector({ profileId }: VoiceInspectorProps) {
|
||||
{/* Save */}
|
||||
{isDirty && (
|
||||
<Button type="submit" className="w-full" disabled={updateProfile.isPending}>
|
||||
{updateProfile.isPending ? 'Saving...' : 'Save Changes'}
|
||||
{updateProfile.isPending
|
||||
? t('profileForm.actions.saving')
|
||||
: t('profileForm.actions.saveChanges')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Mic, Plus, Search, Sparkles } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
@@ -25,6 +26,7 @@ import { useUIStore } from '@/stores/uiStore';
|
||||
import { VoiceInspector } from './VoiceInspector';
|
||||
|
||||
export function VoicesTab() {
|
||||
const { t } = useTranslation();
|
||||
const { data: profiles, isLoading } = useProfiles();
|
||||
const queryClient = useQueryClient();
|
||||
const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
|
||||
@@ -95,7 +97,7 @@ export function VoicesTab() {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-muted-foreground">Loading voices...</div>
|
||||
<div className="text-muted-foreground">{t('voicesTab.loading')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -110,12 +112,12 @@ export function VoicesTab() {
|
||||
{/* Fixed Header */}
|
||||
<div className="absolute top-0 left-0 right-0 z-20 pl-8 pr-8">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<h1 className="text-2xl font-bold">Voices</h1>
|
||||
<h1 className="text-2xl font-bold">{t('voicesTab.title')}</h1>
|
||||
<div className="flex-1" />
|
||||
<div className="relative w-[240px]">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search voices..."
|
||||
placeholder={t('voicesTab.searchPlaceholder')}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="h-10 pl-8 text-sm rounded-full focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
@@ -123,7 +125,7 @@ export function VoicesTab() {
|
||||
</div>
|
||||
<Button onClick={() => setDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
New Voice
|
||||
{t('voicesTab.newVoice')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -139,12 +141,12 @@ export function VoicesTab() {
|
||||
<Table className="table-fixed [&_td:first-child]:pl-8 [&_th:first-child]:pl-8">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[30%]">Name</TableHead>
|
||||
<TableHead className="w-[10%]">Language</TableHead>
|
||||
<TableHead className="w-[10%]">Generations</TableHead>
|
||||
<TableHead className="w-[8%]">Samples</TableHead>
|
||||
<TableHead className="w-[8%]">Effects</TableHead>
|
||||
<TableHead className="w-[24%]">Channels</TableHead>
|
||||
<TableHead className="w-[30%]">{t('voicesTab.columns.name')}</TableHead>
|
||||
<TableHead className="w-[10%]">{t('voicesTab.columns.language')}</TableHead>
|
||||
<TableHead className="w-[10%]">{t('voicesTab.columns.generations')}</TableHead>
|
||||
<TableHead className="w-[8%]">{t('voicesTab.columns.samples')}</TableHead>
|
||||
<TableHead className="w-[8%]">{t('voicesTab.columns.effects')}</TableHead>
|
||||
<TableHead className="w-[24%]">{t('voicesTab.columns.channels')}</TableHead>
|
||||
<TableHead className="w-6"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -194,6 +196,7 @@ function VoiceRow({
|
||||
channels,
|
||||
onChannelChange,
|
||||
}: VoiceRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||
const [avatarError, setAvatarError] = useState(false);
|
||||
const avatarUrl = profile.avatar_path ? `${serverUrl}/profiles/${profile.id}/avatar` : null;
|
||||
@@ -212,7 +215,7 @@ function VoiceRow({
|
||||
{avatarUrl && !avatarError ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={`${profile.name} avatar`}
|
||||
alt={t('voicesTab.avatarAlt', { name: profile.name })}
|
||||
className="h-full w-full object-cover"
|
||||
onError={() => setAvatarError(true)}
|
||||
/>
|
||||
@@ -248,11 +251,11 @@ function VoiceRow({
|
||||
<MultiSelect
|
||||
options={channels.map((ch) => ({
|
||||
value: ch.id,
|
||||
label: `${ch.name}${ch.is_default ? ' (Default)' : ''}`,
|
||||
label: ch.is_default ? t('voicesTab.channelDefaultLabel', { name: ch.name }) : ch.name,
|
||||
}))}
|
||||
value={channelIds}
|
||||
onChange={onChannelChange}
|
||||
placeholder="Select channels..."
|
||||
placeholder={t('voicesTab.selectChannels')}
|
||||
className="w-full"
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
Reference in New Issue
Block a user