mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-29 07:05:14 -07:00
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]>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
ffa68dc356
commit
2b7973df8b
@@ -1,6 +1,7 @@
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Loader2, Play, Save, Trash2, Wand2 } from 'lucide-react';
|
import { Loader2, Play, Save, Trash2, Wand2 } from 'lucide-react';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor';
|
import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor';
|
||||||
import { GenerationPicker } from '@/components/Effects/GenerationPicker';
|
import { GenerationPicker } from '@/components/Effects/GenerationPicker';
|
||||||
@@ -25,6 +26,7 @@ import { useEffectsStore } from '@/stores/effectsStore';
|
|||||||
import { usePlayerStore } from '@/stores/playerStore';
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
|
|
||||||
export function EffectsDetail() {
|
export function EffectsDetail() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const selectedPresetId = useEffectsStore((s) => s.selectedPresetId);
|
const selectedPresetId = useEffectsStore((s) => s.selectedPresetId);
|
||||||
const isCreatingNew = useEffectsStore((s) => s.isCreatingNew);
|
const isCreatingNew = useEffectsStore((s) => s.isCreatingNew);
|
||||||
const workingChain = useEffectsStore((s) => s.workingChain);
|
const workingChain = useEffectsStore((s) => s.workingChain);
|
||||||
@@ -115,8 +117,8 @@ export function EffectsDetail() {
|
|||||||
setAudioWithAutoPlay(url, `preview-${Date.now()}`, null, 'Effects Preview');
|
setAudioWithAutoPlay(url, `preview-${Date.now()}`, null, 'Effects Preview');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Preview failed',
|
title: t('effects.toast.previewFailed'),
|
||||||
description: error instanceof Error ? error.message : 'Unknown error',
|
description: error instanceof Error ? error.message : t('common.unknownError'),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
@@ -130,7 +132,7 @@ export function EffectsDetail() {
|
|||||||
|
|
||||||
async function handleSaveNew() {
|
async function handleSaveNew() {
|
||||||
if (!name.trim()) {
|
if (!name.trim()) {
|
||||||
toast({ title: 'Name required', variant: 'destructive' });
|
toast({ title: t('effects.toast.nameRequired'), variant: 'destructive' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -143,11 +145,14 @@ export function EffectsDetail() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['effect-presets'] });
|
queryClient.invalidateQueries({ queryKey: ['effect-presets'] });
|
||||||
setIsCreatingNew(false);
|
setIsCreatingNew(false);
|
||||||
setSelectedPresetId(created.id);
|
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) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Failed to save',
|
title: t('effects.toast.saveFailed'),
|
||||||
description: error instanceof Error ? error.message : 'Unknown error',
|
description: error instanceof Error ? error.message : t('common.unknownError'),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
@@ -166,11 +171,11 @@ export function EffectsDetail() {
|
|||||||
});
|
});
|
||||||
queryClient.invalidateQueries({ queryKey: ['effect-presets'] });
|
queryClient.invalidateQueries({ queryKey: ['effect-presets'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['effect-preset', selectedPresetId] });
|
queryClient.invalidateQueries({ queryKey: ['effect-preset', selectedPresetId] });
|
||||||
toast({ title: 'Preset updated' });
|
toast({ title: t('effects.toast.updated') });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Failed to save',
|
title: t('effects.toast.saveFailed'),
|
||||||
description: error instanceof Error ? error.message : 'Unknown error',
|
description: error instanceof Error ? error.message : t('common.unknownError'),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
@@ -179,15 +184,14 @@ export function EffectsDetail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleSaveAsNew() {
|
function handleSaveAsNew() {
|
||||||
// Open the dialog with a suggested name based on the current preset
|
setSaveAsName(t('effects.saveAs.suggestedName', { name }));
|
||||||
setSaveAsName(`${name} (Copy)`);
|
|
||||||
setSaveAsDescription(description);
|
setSaveAsDescription(description);
|
||||||
setSaveAsDialogOpen(true);
|
setSaveAsDialogOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSaveAsConfirm() {
|
async function handleSaveAsConfirm() {
|
||||||
if (!saveAsName.trim()) {
|
if (!saveAsName.trim()) {
|
||||||
toast({ title: 'Name required', variant: 'destructive' });
|
toast({ title: t('effects.toast.nameRequired'), variant: 'destructive' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -200,11 +204,14 @@ export function EffectsDetail() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['effect-presets'] });
|
queryClient.invalidateQueries({ queryKey: ['effect-presets'] });
|
||||||
setSaveAsDialogOpen(false);
|
setSaveAsDialogOpen(false);
|
||||||
setSelectedPresetId(created.id);
|
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) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Failed to save',
|
title: t('effects.toast.saveFailed'),
|
||||||
description: error instanceof Error ? error.message : 'Unknown error',
|
description: error instanceof Error ? error.message : t('common.unknownError'),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
@@ -220,11 +227,11 @@ export function EffectsDetail() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['effect-presets'] });
|
queryClient.invalidateQueries({ queryKey: ['effect-presets'] });
|
||||||
setSelectedPresetId(null);
|
setSelectedPresetId(null);
|
||||||
setWorkingChain([]);
|
setWorkingChain([]);
|
||||||
toast({ title: 'Preset deleted' });
|
toast({ title: t('effects.toast.deleted') });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Failed to delete',
|
title: t('effects.toast.deleteFailed'),
|
||||||
description: error instanceof Error ? error.message : 'Unknown error',
|
description: error instanceof Error ? error.message : t('common.unknownError'),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
@@ -237,7 +244,7 @@ export function EffectsDetail() {
|
|||||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||||
<div className="text-center space-y-2">
|
<div className="text-center space-y-2">
|
||||||
<Wand2 className="h-10 w-10 mx-auto opacity-30" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -245,10 +252,13 @@ export function EffectsDetail() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full min-h-0">
|
<div className="flex flex-col h-full min-h-0">
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-lg font-semibold">
|
<h2 className="text-lg font-semibold">
|
||||||
{isCreatingNew ? 'New Preset' : isBuiltIn ? preset?.name : 'Edit Preset'}
|
{isCreatingNew
|
||||||
|
? t('effects.detail.newTitle')
|
||||||
|
: isBuiltIn
|
||||||
|
? preset?.name
|
||||||
|
: t('effects.detail.editTitle')}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{!isBuiltIn && !isCreatingNew && (
|
{!isBuiltIn && !isCreatingNew && (
|
||||||
@@ -261,7 +271,7 @@ export function EffectsDetail() {
|
|||||||
disabled={deleting}
|
disabled={deleting}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
{deleting ? 'Deleting...' : 'Delete'}
|
{deleting ? t('effects.detail.deleting') : t('common.delete')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -270,7 +280,7 @@ export function EffectsDetail() {
|
|||||||
disabled={saving || workingChain.length === 0}
|
disabled={saving || workingChain.length === 0}
|
||||||
>
|
>
|
||||||
<Save className="h-3.5 w-3.5" />
|
<Save className="h-3.5 w-3.5" />
|
||||||
{saving ? 'Saving...' : 'Save'}
|
{saving ? t('effects.detail.saving') : t('common.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -282,7 +292,7 @@ export function EffectsDetail() {
|
|||||||
disabled={saving || workingChain.length === 0}
|
disabled={saving || workingChain.length === 0}
|
||||||
>
|
>
|
||||||
<Save className="h-3.5 w-3.5" />
|
<Save className="h-3.5 w-3.5" />
|
||||||
{saving ? 'Saving...' : 'Save Preset'}
|
{saving ? t('effects.detail.saving') : t('effects.detail.savePreset')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{isBuiltIn && (
|
{isBuiltIn && (
|
||||||
@@ -294,51 +304,46 @@ export function EffectsDetail() {
|
|||||||
disabled={saving}
|
disabled={saving}
|
||||||
>
|
>
|
||||||
<Save className="h-3.5 w-3.5" />
|
<Save className="h-3.5 w-3.5" />
|
||||||
{saving ? 'Saving...' : 'Save as Custom'}
|
{saving ? t('effects.detail.saving') : t('effects.detail.saveAsCustom')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scrollable content */}
|
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto space-y-5 pr-1">
|
<div className="flex-1 min-h-0 overflow-y-auto space-y-5 pr-1">
|
||||||
{/* Name & description */}
|
|
||||||
{(isCreatingNew || !isBuiltIn) && (
|
{(isCreatingNew || !isBuiltIn) && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs">Name</Label>
|
<Label className="text-xs">{t('effects.fields.name')}</Label>
|
||||||
<Input
|
<Input
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="My preset..."
|
placeholder={t('effects.fields.namePlaceholder')}
|
||||||
className="h-9"
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs">Description</Label>
|
<Label className="text-xs">{t('effects.fields.description')}</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
placeholder="Describe what this preset does..."
|
placeholder={t('effects.fields.descriptionPlaceholder')}
|
||||||
className="min-h-[60px] resize-none"
|
className="min-h-[60px] resize-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Built-in description (read-only) */}
|
|
||||||
{isBuiltIn && preset?.description && (
|
{isBuiltIn && preset?.description && (
|
||||||
<p className="text-sm text-muted-foreground">{preset.description}</p>
|
<p className="text-sm text-muted-foreground">{preset.description}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Effects chain editor */}
|
|
||||||
<EffectsChainEditor value={workingChain} onChange={setWorkingChain} showPresets={false} />
|
<EffectsChainEditor value={workingChain} onChange={setWorkingChain} showPresets={false} />
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
{/* Preview section */}
|
|
||||||
<div className="space-y-3">
|
<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">
|
<div className="flex items-center gap-2">
|
||||||
<GenerationPicker
|
<GenerationPicker
|
||||||
selectedId={previewGenId}
|
selectedId={previewGenId}
|
||||||
@@ -355,38 +360,33 @@ export function EffectsDetail() {
|
|||||||
{previewLoading ? (
|
{previewLoading ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
Processing...
|
{t('effects.preview.processing')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Play className="h-3.5 w-3.5" />
|
<Play className="h-3.5 w-3.5" />
|
||||||
Preview
|
{t('effects.preview.button')}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-muted-foreground">
|
<p className="text-[11px] text-muted-foreground">{t('effects.preview.hint')}</p>
|
||||||
Preview applies effects to the clean version without saving.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Save as Custom dialog */}
|
|
||||||
<Dialog open={saveAsDialogOpen} onOpenChange={setSaveAsDialogOpen}>
|
<Dialog open={saveAsDialogOpen} onOpenChange={setSaveAsDialogOpen}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Save as Custom Preset</DialogTitle>
|
<DialogTitle>{t('effects.saveAs.title')}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>{t('effects.saveAs.description')}</DialogDescription>
|
||||||
Create a new custom preset based on the current effects chain.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-3 py-2">
|
<div className="space-y-3 py-2">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs">Name</Label>
|
<Label className="text-xs">{t('effects.fields.name')}</Label>
|
||||||
<Input
|
<Input
|
||||||
value={saveAsName}
|
value={saveAsName}
|
||||||
onChange={(e) => setSaveAsName(e.target.value)}
|
onChange={(e) => setSaveAsName(e.target.value)}
|
||||||
placeholder="My preset..."
|
placeholder={t('effects.fields.namePlaceholder')}
|
||||||
className="h-9"
|
className="h-9"
|
||||||
autoFocus
|
autoFocus
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
@@ -397,22 +397,22 @@ export function EffectsDetail() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs">Description</Label>
|
<Label className="text-xs">{t('effects.fields.description')}</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={saveAsDescription}
|
value={saveAsDescription}
|
||||||
onChange={(e) => setSaveAsDescription(e.target.value)}
|
onChange={(e) => setSaveAsDescription(e.target.value)}
|
||||||
placeholder="Describe what this preset does..."
|
placeholder={t('effects.fields.descriptionPlaceholder')}
|
||||||
className="min-h-[60px] resize-none"
|
className="min-h-[60px] resize-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setSaveAsDialogOpen(false)} disabled={saving}>
|
<Button variant="outline" onClick={() => setSaveAsDialogOpen(false)} disabled={saving}>
|
||||||
Cancel
|
{t('common.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSaveAsConfirm} disabled={saving || !saveAsName.trim()}>
|
<Button onClick={handleSaveAsConfirm} disabled={saving || !saveAsName.trim()}>
|
||||||
<Save className="h-3.5 w-3.5 mr-1.5" />
|
<Save className="h-3.5 w-3.5 mr-1.5" />
|
||||||
{saving ? 'Saving...' : 'Save'}
|
{saving ? t('effects.detail.saving') : t('common.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { Loader2, Plus, Sparkles, Wand2 } from 'lucide-react';
|
import { Loader2, Plus, Sparkles, Wand2 } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import type { EffectPresetResponse } from '@/lib/api/types';
|
import type { EffectPresetResponse } from '@/lib/api/types';
|
||||||
@@ -7,6 +8,7 @@ import { cn } from '@/lib/utils/cn';
|
|||||||
import { useEffectsStore } from '@/stores/effectsStore';
|
import { useEffectsStore } from '@/stores/effectsStore';
|
||||||
|
|
||||||
export function EffectsList() {
|
export function EffectsList() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const selectedPresetId = useEffectsStore((s) => s.selectedPresetId);
|
const selectedPresetId = useEffectsStore((s) => s.selectedPresetId);
|
||||||
const setSelectedPresetId = useEffectsStore((s) => s.setSelectedPresetId);
|
const setSelectedPresetId = useEffectsStore((s) => s.setSelectedPresetId);
|
||||||
const setWorkingChain = useEffectsStore((s) => s.setWorkingChain);
|
const setWorkingChain = useEffectsStore((s) => s.setWorkingChain);
|
||||||
@@ -44,10 +46,10 @@ export function EffectsList() {
|
|||||||
<div className="flex flex-col h-full min-h-0">
|
<div className="flex flex-col h-full min-h-0">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between mb-4">
|
<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}>
|
<Button variant="outline" size="sm" className="h-8 gap-1.5" onClick={handleCreateNew}>
|
||||||
<Plus className="h-3.5 w-3.5" />
|
<Plus className="h-3.5 w-3.5" />
|
||||||
New Preset
|
{t('effects.newPreset')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -57,7 +59,7 @@ export function EffectsList() {
|
|||||||
{builtIn.length > 0 && (
|
{builtIn.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
|
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
|
||||||
Built-in
|
{t('effects.sections.builtin')}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{builtIn.map((preset) => (
|
{builtIn.map((preset) => (
|
||||||
@@ -76,7 +78,7 @@ export function EffectsList() {
|
|||||||
{userPresets.length > 0 && (
|
{userPresets.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
|
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
|
||||||
Custom
|
{t('effects.sections.custom')}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{userPresets.map((preset) => (
|
{userPresets.map((preset) => (
|
||||||
@@ -95,16 +97,14 @@ export function EffectsList() {
|
|||||||
{isCreatingNew && (
|
{isCreatingNew && (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
|
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
|
||||||
New
|
{t('effects.sections.new')}
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl border-2 border-accent/40 bg-accent/5 p-3">
|
<div className="rounded-xl border-2 border-accent/40 bg-accent/5 p-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Sparkles className="h-4 w-4 text-accent" />
|
<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>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground mt-1">{t('effects.unsaved.hint')}</p>
|
||||||
Configure effects in the panel on the right.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -122,6 +122,7 @@ function PresetCard({
|
|||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const effectCount = preset.effects_chain.length;
|
const effectCount = preset.effects_chain.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -142,16 +143,16 @@ function PresetCard({
|
|||||||
<span className="text-sm font-medium truncate">{preset.name}</span>
|
<span className="text-sm font-medium truncate">{preset.name}</span>
|
||||||
{preset.is_builtin && (
|
{preset.is_builtin && (
|
||||||
<span className="text-[10px] bg-muted text-muted-foreground px-1.5 py-0.5 rounded-full shrink-0">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-1 pl-6">
|
<p className="text-xs text-muted-foreground mt-1 line-clamp-1 pl-6">
|
||||||
{preset.description || 'No description'}
|
{preset.description || t('effects.noDescription')}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2 mt-1.5 pl-6">
|
<div className="flex items-center gap-2 mt-1.5 pl-6">
|
||||||
<span className="text-[10px] text-muted-foreground">
|
<span className="text-[10px] text-muted-foreground">
|
||||||
{effectCount} effect{effectCount !== 1 ? 's' : ''}
|
{t('effects.effectCount', { count: effectCount })}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] text-muted-foreground/50">
|
<span className="text-[10px] text-muted-foreground/50">
|
||||||
{preset.effects_chain
|
{preset.effects_chain
|
||||||
|
|||||||
@@ -37,6 +37,61 @@
|
|||||||
"deleting": "Deleting…"
|
"deleting": "Deleting…"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"effects": {
|
||||||
|
"title": "Effects",
|
||||||
|
"newPreset": "New Preset",
|
||||||
|
"noDescription": "No description",
|
||||||
|
"placeholder": "Select a preset or create a new one",
|
||||||
|
"effectCount_one": "{{count}} effect",
|
||||||
|
"effectCount_other": "{{count}} effects",
|
||||||
|
"sections": {
|
||||||
|
"builtin": "Built-in",
|
||||||
|
"custom": "Custom",
|
||||||
|
"new": "New"
|
||||||
|
},
|
||||||
|
"badge": {
|
||||||
|
"builtin": "built-in"
|
||||||
|
},
|
||||||
|
"unsaved": {
|
||||||
|
"title": "Unsaved Preset",
|
||||||
|
"hint": "Configure effects in the panel on the right."
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"newTitle": "New Preset",
|
||||||
|
"editTitle": "Edit Preset",
|
||||||
|
"savePreset": "Save Preset",
|
||||||
|
"saveAsCustom": "Save as Custom",
|
||||||
|
"saving": "Saving…",
|
||||||
|
"deleting": "Deleting…"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"name": "Name",
|
||||||
|
"namePlaceholder": "My preset…",
|
||||||
|
"description": "Description",
|
||||||
|
"descriptionPlaceholder": "Describe what this preset does…"
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"label": "Preview",
|
||||||
|
"button": "Preview",
|
||||||
|
"processing": "Processing…",
|
||||||
|
"hint": "Preview applies effects to the clean version without saving."
|
||||||
|
},
|
||||||
|
"saveAs": {
|
||||||
|
"title": "Save as Custom Preset",
|
||||||
|
"description": "Create a new custom preset based on the current effects chain.",
|
||||||
|
"suggestedName": "{{name}} (Copy)"
|
||||||
|
},
|
||||||
|
"toast": {
|
||||||
|
"saved": "Preset saved",
|
||||||
|
"createdDescription": "\"{{name}}\" has been created.",
|
||||||
|
"updated": "Preset updated",
|
||||||
|
"deleted": "Preset deleted",
|
||||||
|
"saveFailed": "Failed to save",
|
||||||
|
"deleteFailed": "Failed to delete",
|
||||||
|
"previewFailed": "Preview failed",
|
||||||
|
"nameRequired": "Name required"
|
||||||
|
}
|
||||||
|
},
|
||||||
"stories": {
|
"stories": {
|
||||||
"title": "Stories",
|
"title": "Stories",
|
||||||
"newStory": "New Story",
|
"newStory": "New Story",
|
||||||
|
|||||||
@@ -37,6 +37,61 @@
|
|||||||
"deleting": "删除中…"
|
"deleting": "删除中…"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"effects": {
|
||||||
|
"title": "效果",
|
||||||
|
"newPreset": "新建预设",
|
||||||
|
"noDescription": "无描述",
|
||||||
|
"placeholder": "选择一个预设或创建新的",
|
||||||
|
"effectCount_one": "{{count}} 个效果",
|
||||||
|
"effectCount_other": "{{count}} 个效果",
|
||||||
|
"sections": {
|
||||||
|
"builtin": "内置",
|
||||||
|
"custom": "自定义",
|
||||||
|
"new": "新建"
|
||||||
|
},
|
||||||
|
"badge": {
|
||||||
|
"builtin": "内置"
|
||||||
|
},
|
||||||
|
"unsaved": {
|
||||||
|
"title": "未保存的预设",
|
||||||
|
"hint": "在右侧面板配置效果。"
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"newTitle": "新建预设",
|
||||||
|
"editTitle": "编辑预设",
|
||||||
|
"savePreset": "保存预设",
|
||||||
|
"saveAsCustom": "另存为自定义",
|
||||||
|
"saving": "保存中…",
|
||||||
|
"deleting": "删除中…"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"name": "名称",
|
||||||
|
"namePlaceholder": "我的预设……",
|
||||||
|
"description": "描述",
|
||||||
|
"descriptionPlaceholder": "描述此预设的作用……"
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"label": "预览",
|
||||||
|
"button": "预览",
|
||||||
|
"processing": "处理中…",
|
||||||
|
"hint": "预览仅将效果应用于干净版本,不会保存。"
|
||||||
|
},
|
||||||
|
"saveAs": {
|
||||||
|
"title": "另存为自定义预设",
|
||||||
|
"description": "基于当前效果链创建一个新的自定义预设。",
|
||||||
|
"suggestedName": "{{name}}(副本)"
|
||||||
|
},
|
||||||
|
"toast": {
|
||||||
|
"saved": "预设已保存",
|
||||||
|
"createdDescription": "\"{{name}}\" 已创建。",
|
||||||
|
"updated": "预设已更新",
|
||||||
|
"deleted": "预设已删除",
|
||||||
|
"saveFailed": "保存失败",
|
||||||
|
"deleteFailed": "删除失败",
|
||||||
|
"previewFailed": "预览失败",
|
||||||
|
"nameRequired": "请输入名称"
|
||||||
|
}
|
||||||
|
},
|
||||||
"stories": {
|
"stories": {
|
||||||
"title": "故事",
|
"title": "故事",
|
||||||
"newStory": "新建故事",
|
"newStory": "新建故事",
|
||||||
|
|||||||
Reference in New Issue
Block a user