mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-10-03 09:05:17 -07:00
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]>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
dd4119e0e6
commit
992ad96a29
@@ -17,6 +17,7 @@ import { Link } from '@tanstack/react-router';
|
|||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import { Download, Plus } from 'lucide-react';
|
import { Download, Plus } from 'lucide-react';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import Loader from 'react-loaders';
|
import Loader from 'react-loaders';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -36,6 +37,7 @@ import { useStoryStore } from '@/stores/storyStore';
|
|||||||
import { SortableStoryChatItem } from './StoryChatItem';
|
import { SortableStoryChatItem } from './StoryChatItem';
|
||||||
|
|
||||||
export function StoryContent() {
|
export function StoryContent() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
||||||
const { data: story, isLoading } = useStory(selectedStoryId);
|
const { data: story, isLoading } = useStory(selectedStoryId);
|
||||||
const removeItem = useRemoveStoryItem();
|
const removeItem = useRemoveStoryItem();
|
||||||
@@ -147,7 +149,7 @@ export function StoryContent() {
|
|||||||
{
|
{
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast({
|
toast({
|
||||||
title: 'Failed to remove item',
|
title: t('storyContent.toast.removeFailed'),
|
||||||
description: error.message,
|
description: error.message,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
@@ -179,7 +181,7 @@ export function StoryContent() {
|
|||||||
{
|
{
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast({
|
toast({
|
||||||
title: 'Failed to reorder items',
|
title: t('storyContent.toast.reorderFailed'),
|
||||||
description: error.message,
|
description: error.message,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
@@ -199,7 +201,7 @@ export function StoryContent() {
|
|||||||
{
|
{
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast({
|
toast({
|
||||||
title: 'Failed to export audio',
|
title: t('storyContent.toast.exportFailed'),
|
||||||
description: error.message,
|
description: error.message,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
@@ -223,7 +225,7 @@ export function StoryContent() {
|
|||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast({
|
toast({
|
||||||
title: 'Failed to add generation',
|
title: t('storyContent.toast.addFailed'),
|
||||||
description: error.message,
|
description: error.message,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
@@ -236,8 +238,8 @@ export function StoryContent() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-lg font-medium mb-2">Select a story</p>
|
<p className="text-lg font-medium mb-2">{t('storyContent.selectStory.title')}</p>
|
||||||
<p className="text-sm">Choose a story from the list to view its content</p>
|
<p className="text-sm">{t('storyContent.selectStory.hint')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -246,7 +248,7 @@ export function StoryContent() {
|
|||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -255,8 +257,8 @@ export function StoryContent() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-lg font-medium mb-2">Story not found</p>
|
<p className="text-lg font-medium mb-2">{t('storyContent.notFound.title')}</p>
|
||||||
<p className="text-sm">The selected story could not be loaded</p>
|
<p className="text-sm">{t('storyContent.notFound.hint')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -291,7 +293,7 @@ export function StoryContent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
Generating {pendingCount} {pendingCount === 1 ? 'audio' : 'audios'}
|
{t('storyContent.generatingCount', { count: pendingCount })}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -301,13 +303,13 @@ export function StoryContent() {
|
|||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm">
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Add
|
{t('storyContent.add')}
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-80 p-0" align="end">
|
<PopoverContent className="w-80 p-0" align="end">
|
||||||
<div className="p-2 border-b">
|
<div className="p-2 border-b">
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search by name or transcript..."
|
placeholder={t('storyContent.searchPlaceholder')}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
autoFocus
|
autoFocus
|
||||||
@@ -316,7 +318,9 @@ export function StoryContent() {
|
|||||||
<div className="max-h-60 overflow-y-auto">
|
<div className="max-h-60 overflow-y-auto">
|
||||||
{availableGenerations.length === 0 ? (
|
{availableGenerations.length === 0 ? (
|
||||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
availableGenerations.map((gen) => (
|
availableGenerations.map((gen) => (
|
||||||
@@ -344,7 +348,7 @@ export function StoryContent() {
|
|||||||
disabled={exportAudio.isPending}
|
disabled={exportAudio.isPending}
|
||||||
>
|
>
|
||||||
<Download className="mr-2 h-4 w-4" />
|
<Download className="mr-2 h-4 w-4" />
|
||||||
Export Audio
|
{t('storyContent.exportAudio')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -358,8 +362,8 @@ export function StoryContent() {
|
|||||||
>
|
>
|
||||||
{sortedItems.length === 0 ? (
|
{sortedItems.length === 0 ? (
|
||||||
<div className="text-center py-12 px-5 border-2 border-dashed border-muted rounded-md text-muted-foreground">
|
<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-sm">{t('storyContent.empty.title')}</p>
|
||||||
<p className="text-xs mt-2">Generate speech using the box below to add items</p>
|
<p className="text-xs mt-2">{t('storyContent.empty.hint')}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<DndContext
|
<DndContext
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { BookOpen, MoreHorizontal, Pencil, Plus, Trash2 } from 'lucide-react';
|
import { BookOpen, MoreHorizontal, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -41,6 +42,7 @@ import { formatDate } from '@/lib/utils/format';
|
|||||||
import { useStoryStore } from '@/stores/storyStore';
|
import { useStoryStore } from '@/stores/storyStore';
|
||||||
|
|
||||||
export function StoryList() {
|
export function StoryList() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { data: stories, isLoading } = useStories();
|
const { data: stories, isLoading } = useStories();
|
||||||
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
||||||
const setSelectedStoryId = useStoryStore((state) => state.setSelectedStoryId);
|
const setSelectedStoryId = useStoryStore((state) => state.setSelectedStoryId);
|
||||||
@@ -72,8 +74,8 @@ export function StoryList() {
|
|||||||
const handleCreateStory = () => {
|
const handleCreateStory = () => {
|
||||||
if (!newStoryName.trim()) {
|
if (!newStoryName.trim()) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Name required',
|
title: t('stories.toast.nameRequired'),
|
||||||
description: 'Please enter a story name',
|
description: t('stories.toast.nameRequiredDescription'),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -91,13 +93,13 @@ export function StoryList() {
|
|||||||
setNewStoryName('');
|
setNewStoryName('');
|
||||||
setNewStoryDescription('');
|
setNewStoryDescription('');
|
||||||
toast({
|
toast({
|
||||||
title: 'Story created',
|
title: t('stories.toast.created'),
|
||||||
description: `"${story.name}" has been created`,
|
description: t('stories.toast.createdDescription', { name: story.name }),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast({
|
toast({
|
||||||
title: 'Failed to create story',
|
title: t('stories.toast.createFailed'),
|
||||||
description: error.message,
|
description: error.message,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
@@ -116,8 +118,8 @@ export function StoryList() {
|
|||||||
const handleUpdateStory = () => {
|
const handleUpdateStory = () => {
|
||||||
if (!editingStory || !newStoryName.trim()) {
|
if (!editingStory || !newStoryName.trim()) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Name required',
|
title: t('stories.toast.nameRequired'),
|
||||||
description: 'Please enter a story name',
|
description: t('stories.toast.nameRequiredDescription'),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -140,7 +142,7 @@ export function StoryList() {
|
|||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast({
|
toast({
|
||||||
title: 'Failed to update story',
|
title: t('stories.toast.updateFailed'),
|
||||||
description: error.message,
|
description: error.message,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
@@ -168,7 +170,7 @@ export function StoryList() {
|
|||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast({
|
toast({
|
||||||
title: 'Failed to delete story',
|
title: t('stories.toast.deleteFailed'),
|
||||||
description: error.message,
|
description: error.message,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
@@ -179,7 +181,7 @@ export function StoryList() {
|
|||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -195,10 +197,10 @@ export function StoryList() {
|
|||||||
{/* Fixed Header */}
|
{/* Fixed Header */}
|
||||||
<div className="absolute top-0 left-0 right-0 z-20">
|
<div className="absolute top-0 left-0 right-0 z-20">
|
||||||
<div className="flex items-center justify-between mb-4 px-1">
|
<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">
|
<Button onClick={() => setCreateDialogOpen(true)} size="sm">
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
New Story
|
{t('stories.newStory')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -211,8 +213,8 @@ export function StoryList() {
|
|||||||
{storyList.length === 0 ? (
|
{storyList.length === 0 ? (
|
||||||
<div className="text-center py-12 px-5 border-2 border-dashed border-muted rounded-2xl text-muted-foreground">
|
<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" />
|
<BookOpen className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||||
<p className="text-sm">No stories yet</p>
|
<p className="text-sm">{t('stories.empty.title')}</p>
|
||||||
<p className="text-xs mt-2">Create your first story to get started</p>
|
<p className="text-xs mt-2">{t('stories.empty.hint')}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-0.5">
|
<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',
|
'px-5 py-3 rounded-lg transition-colors group flex items-center cursor-pointer',
|
||||||
selectedStoryId === story.id ? 'bg-muted' : 'hover:bg-muted/50',
|
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}
|
aria-pressed={selectedStoryId === story.id}
|
||||||
onClick={() => setSelectedStoryId(story.id)}
|
onClick={() => setSelectedStoryId(story.id)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
@@ -240,9 +246,7 @@ export function StoryList() {
|
|||||||
<div className="flex-1 min-w-0 text-left overflow-hidden">
|
<div className="flex-1 min-w-0 text-left overflow-hidden">
|
||||||
<h3 className="text-sm font-medium truncate">{story.name}</h3>
|
<h3 className="text-sm font-medium truncate">{story.name}</h3>
|
||||||
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground">
|
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground">
|
||||||
<span>
|
<span>{t('stories.row.itemCount', { count: story.item_count })}</span>
|
||||||
{story.item_count} {story.item_count === 1 ? 'item' : 'items'}
|
|
||||||
</span>
|
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
<span>{formatDate(story.updated_at)}</span>
|
<span>{formatDate(story.updated_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -254,7 +258,7 @@ export function StoryList() {
|
|||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
|
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
onClick={(e) => e.stopPropagation()}
|
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" />
|
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -262,14 +266,14 @@ export function StoryList() {
|
|||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem onClick={() => handleEditClick(story)}>
|
<DropdownMenuItem onClick={() => handleEditClick(story)}>
|
||||||
<Pencil className="mr-2 h-4 w-4" />
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
Edit
|
{t('common.edit')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => handleDeleteClick(story.id)}
|
onClick={() => handleDeleteClick(story.id)}
|
||||||
className="text-destructive focus:text-destructive"
|
className="text-destructive focus:text-destructive"
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
Delete
|
{t('common.delete')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
@@ -284,17 +288,15 @@ export function StoryList() {
|
|||||||
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Create New Story</DialogTitle>
|
<DialogTitle>{t('stories.createDialog.title')}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>{t('stories.createDialog.description')}</DialogDescription>
|
||||||
Create a new story to organize your voice generations into conversations.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4 py-4">
|
<div className="space-y-4 py-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="story-name">Name</Label>
|
<Label htmlFor="story-name">{t('stories.fields.name')}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="story-name"
|
id="story-name"
|
||||||
placeholder="My Story"
|
placeholder={t('stories.fields.namePlaceholder')}
|
||||||
value={newStoryName}
|
value={newStoryName}
|
||||||
onChange={(e) => setNewStoryName(e.target.value)}
|
onChange={(e) => setNewStoryName(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
@@ -305,10 +307,10 @@ export function StoryList() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="story-description">Description (optional)</Label>
|
<Label htmlFor="story-description">{t('stories.fields.descriptionLabel')}</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="story-description"
|
id="story-description"
|
||||||
placeholder="A conversation between..."
|
placeholder={t('stories.fields.descriptionPlaceholder')}
|
||||||
value={newStoryDescription}
|
value={newStoryDescription}
|
||||||
onChange={(e) => setNewStoryDescription(e.target.value)}
|
onChange={(e) => setNewStoryDescription(e.target.value)}
|
||||||
rows={3}
|
rows={3}
|
||||||
@@ -317,28 +319,29 @@ export function StoryList() {
|
|||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}>
|
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}>
|
||||||
Cancel
|
{t('common.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleCreateStory} disabled={createStory.isPending}>
|
<Button onClick={handleCreateStory} disabled={createStory.isPending}>
|
||||||
{createStory.isPending ? 'Creating...' : 'Create'}
|
{createStory.isPending
|
||||||
|
? t('stories.createDialog.creating')
|
||||||
|
: t('stories.createDialog.action')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* Edit Story Dialog */}
|
|
||||||
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
|
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Edit Story</DialogTitle>
|
<DialogTitle>{t('stories.editDialog.title')}</DialogTitle>
|
||||||
<DialogDescription>Update the story name and description.</DialogDescription>
|
<DialogDescription>{t('stories.editDialog.description')}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4 py-4">
|
<div className="space-y-4 py-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="edit-story-name">Name</Label>
|
<Label htmlFor="edit-story-name">{t('stories.fields.name')}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="edit-story-name"
|
id="edit-story-name"
|
||||||
placeholder="My Story"
|
placeholder={t('stories.fields.namePlaceholder')}
|
||||||
value={newStoryName}
|
value={newStoryName}
|
||||||
onChange={(e) => setNewStoryName(e.target.value)}
|
onChange={(e) => setNewStoryName(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
@@ -349,10 +352,10 @@ export function StoryList() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="edit-story-description">Description (optional)</Label>
|
<Label htmlFor="edit-story-description">{t('stories.fields.descriptionLabel')}</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="edit-story-description"
|
id="edit-story-description"
|
||||||
placeholder="A conversation between..."
|
placeholder={t('stories.fields.descriptionPlaceholder')}
|
||||||
value={newStoryDescription}
|
value={newStoryDescription}
|
||||||
onChange={(e) => setNewStoryDescription(e.target.value)}
|
onChange={(e) => setNewStoryDescription(e.target.value)}
|
||||||
rows={3}
|
rows={3}
|
||||||
@@ -361,34 +364,30 @@ export function StoryList() {
|
|||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setEditDialogOpen(false)}>
|
<Button variant="outline" onClick={() => setEditDialogOpen(false)}>
|
||||||
Cancel
|
{t('common.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleUpdateStory} disabled={updateStory.isPending}>
|
<Button onClick={handleUpdateStory} disabled={updateStory.isPending}>
|
||||||
{updateStory.isPending ? 'Saving...' : 'Save'}
|
{updateStory.isPending ? t('stories.editDialog.saving') : t('common.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* Delete Story Confirmation Dialog */}
|
|
||||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
<AlertDialogTitle>{t('stories.deleteDialog.title')}</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>{t('stories.deleteDialog.description')}</AlertDialogDescription>
|
||||||
This will permanently delete the story and all its items. This action cannot be
|
|
||||||
undone.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
|
||||||
<AlertDialogAction asChild>
|
<AlertDialogAction asChild>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleDeleteConfirm}
|
onClick={handleDeleteConfirm}
|
||||||
disabled={deleteStory.isPending}
|
disabled={deleteStory.isPending}
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
>
|
>
|
||||||
{deleteStory.isPending ? 'Deleting...' : 'Delete'}
|
{deleteStory.isPending ? t('stories.deleteDialog.deleting') : t('common.delete')}
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
|
|||||||
@@ -37,6 +37,80 @@
|
|||||||
"deleting": "Deleting…"
|
"deleting": "Deleting…"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"stories": {
|
||||||
|
"title": "Stories",
|
||||||
|
"newStory": "New Story",
|
||||||
|
"loading": "Loading stories…",
|
||||||
|
"empty": {
|
||||||
|
"title": "No stories yet",
|
||||||
|
"hint": "Create your first story to get started"
|
||||||
|
},
|
||||||
|
"row": {
|
||||||
|
"itemCount_one": "{{count}} item",
|
||||||
|
"itemCount_other": "{{count}} items",
|
||||||
|
"ariaLabel": "Story {{name}}, {{count}} items, {{updated}}",
|
||||||
|
"actionsLabel": "Actions for {{name}}"
|
||||||
|
},
|
||||||
|
"createDialog": {
|
||||||
|
"title": "Create New Story",
|
||||||
|
"description": "Create a new story to organize your voice generations into conversations.",
|
||||||
|
"action": "Create",
|
||||||
|
"creating": "Creating…"
|
||||||
|
},
|
||||||
|
"editDialog": {
|
||||||
|
"title": "Edit Story",
|
||||||
|
"description": "Update the story name and description.",
|
||||||
|
"saving": "Saving…"
|
||||||
|
},
|
||||||
|
"deleteDialog": {
|
||||||
|
"title": "Are you sure?",
|
||||||
|
"description": "This will permanently delete the story and all its items. This action cannot be undone.",
|
||||||
|
"deleting": "Deleting…"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"name": "Name",
|
||||||
|
"namePlaceholder": "My Story",
|
||||||
|
"descriptionLabel": "Description (optional)",
|
||||||
|
"descriptionPlaceholder": "A conversation between…"
|
||||||
|
},
|
||||||
|
"toast": {
|
||||||
|
"nameRequired": "Name required",
|
||||||
|
"nameRequiredDescription": "Please enter a story name",
|
||||||
|
"created": "Story created",
|
||||||
|
"createdDescription": "\"{{name}}\" has been created",
|
||||||
|
"createFailed": "Failed to create story",
|
||||||
|
"updateFailed": "Failed to update story",
|
||||||
|
"deleteFailed": "Failed to delete story"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"storyContent": {
|
||||||
|
"selectStory": {
|
||||||
|
"title": "Select a story",
|
||||||
|
"hint": "Choose a story from the list to view its content"
|
||||||
|
},
|
||||||
|
"loading": "Loading story…",
|
||||||
|
"notFound": {
|
||||||
|
"title": "Story not found",
|
||||||
|
"hint": "The selected story could not be loaded"
|
||||||
|
},
|
||||||
|
"generatingCount_one": "Generating {{count}} audio",
|
||||||
|
"generatingCount_other": "Generating {{count}} audios",
|
||||||
|
"add": "Add",
|
||||||
|
"searchPlaceholder": "Search by name or transcript…",
|
||||||
|
"searchNoMatches": "No matching generations found",
|
||||||
|
"searchNoAvailable": "No available generations",
|
||||||
|
"exportAudio": "Export Audio",
|
||||||
|
"empty": {
|
||||||
|
"title": "No items in this story",
|
||||||
|
"hint": "Generate speech using the box below to add items"
|
||||||
|
},
|
||||||
|
"toast": {
|
||||||
|
"removeFailed": "Failed to remove item",
|
||||||
|
"reorderFailed": "Failed to reorder items",
|
||||||
|
"exportFailed": "Failed to export audio",
|
||||||
|
"addFailed": "Failed to add generation"
|
||||||
|
}
|
||||||
|
},
|
||||||
"history": {
|
"history": {
|
||||||
"deleteDialog": {
|
"deleteDialog": {
|
||||||
"title": "Delete Generation",
|
"title": "Delete Generation",
|
||||||
|
|||||||
@@ -37,6 +37,80 @@
|
|||||||
"deleting": "删除中…"
|
"deleting": "删除中…"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"stories": {
|
||||||
|
"title": "故事",
|
||||||
|
"newStory": "新建故事",
|
||||||
|
"loading": "加载故事中…",
|
||||||
|
"empty": {
|
||||||
|
"title": "暂无故事",
|
||||||
|
"hint": "创建您的第一个故事以开始"
|
||||||
|
},
|
||||||
|
"row": {
|
||||||
|
"itemCount_one": "{{count}} 项",
|
||||||
|
"itemCount_other": "{{count}} 项",
|
||||||
|
"ariaLabel": "故事 {{name}},{{count}} 项,{{updated}}",
|
||||||
|
"actionsLabel": "{{name}} 的操作"
|
||||||
|
},
|
||||||
|
"createDialog": {
|
||||||
|
"title": "新建故事",
|
||||||
|
"description": "创建新故事以将您的语音生成整理成对话。",
|
||||||
|
"action": "创建",
|
||||||
|
"creating": "创建中…"
|
||||||
|
},
|
||||||
|
"editDialog": {
|
||||||
|
"title": "编辑故事",
|
||||||
|
"description": "更新故事名称和描述。",
|
||||||
|
"saving": "保存中…"
|
||||||
|
},
|
||||||
|
"deleteDialog": {
|
||||||
|
"title": "确定吗?",
|
||||||
|
"description": "这将永久删除该故事及其所有项目。此操作不可撤销。",
|
||||||
|
"deleting": "删除中…"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"name": "名称",
|
||||||
|
"namePlaceholder": "我的故事",
|
||||||
|
"descriptionLabel": "描述(可选)",
|
||||||
|
"descriptionPlaceholder": "一段对话……"
|
||||||
|
},
|
||||||
|
"toast": {
|
||||||
|
"nameRequired": "请输入名称",
|
||||||
|
"nameRequiredDescription": "请输入故事名称",
|
||||||
|
"created": "故事已创建",
|
||||||
|
"createdDescription": "\"{{name}}\" 已创建",
|
||||||
|
"createFailed": "创建故事失败",
|
||||||
|
"updateFailed": "更新故事失败",
|
||||||
|
"deleteFailed": "删除故事失败"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"storyContent": {
|
||||||
|
"selectStory": {
|
||||||
|
"title": "选择一个故事",
|
||||||
|
"hint": "从列表中选择一个故事以查看其内容"
|
||||||
|
},
|
||||||
|
"loading": "加载故事中…",
|
||||||
|
"notFound": {
|
||||||
|
"title": "未找到故事",
|
||||||
|
"hint": "无法加载所选故事"
|
||||||
|
},
|
||||||
|
"generatingCount_one": "生成 {{count}} 个音频中",
|
||||||
|
"generatingCount_other": "生成 {{count}} 个音频中",
|
||||||
|
"add": "添加",
|
||||||
|
"searchPlaceholder": "按名称或文字内容搜索……",
|
||||||
|
"searchNoMatches": "未找到匹配的生成",
|
||||||
|
"searchNoAvailable": "暂无可用的生成",
|
||||||
|
"exportAudio": "导出音频",
|
||||||
|
"empty": {
|
||||||
|
"title": "此故事暂无项目",
|
||||||
|
"hint": "使用下方输入框生成语音以添加项目"
|
||||||
|
},
|
||||||
|
"toast": {
|
||||||
|
"removeFailed": "移除项目失败",
|
||||||
|
"reorderFailed": "重新排序项目失败",
|
||||||
|
"exportFailed": "导出音频失败",
|
||||||
|
"addFailed": "添加生成失败"
|
||||||
|
}
|
||||||
|
},
|
||||||
"history": {
|
"history": {
|
||||||
"deleteDialog": {
|
"deleteDialog": {
|
||||||
"title": "删除生成",
|
"title": "删除生成",
|
||||||
|
|||||||
Reference in New Issue
Block a user