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:
James Pine
2026-04-20 03:02:21 -07:00
co-authored by Claude Opus 4.7
parent dd4119e0e6
commit 992ad96a29
4 changed files with 215 additions and 64 deletions
+20 -16
View File
@@ -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
+47 -48
View File
@@ -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>
+74
View File
@@ -37,6 +37,80 @@
"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": {
"deleteDialog": {
"title": "Delete Generation",
@@ -37,6 +37,80 @@
"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": {
"deleteDialog": {
"title": "删除生成",