mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-10-03 09:05:17 -07:00
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]>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
302e28e313
commit
dd4119e0e6
@@ -14,6 +14,7 @@ import {
|
|||||||
Wand2,
|
Wand2,
|
||||||
} from 'lucide-react';
|
} 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 { Button } from '@/components/ui/button';
|
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
|
// NEW ALTERNATE HISTORY VIEW - FIXED HEIGHT ROWS WITH INFINITE SCROLL
|
||||||
export function HistoryTable() {
|
export function HistoryTable() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
const [allHistory, setAllHistory] = useState<HistoryResponse[]>([]);
|
const [allHistory, setAllHistory] = useState<HistoryResponse[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
@@ -678,7 +680,9 @@ export function HistoryTable() {
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<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 className="mr-2 h-4 w-4" />
|
||||||
Play
|
Play
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -802,10 +806,9 @@ export function HistoryTable() {
|
|||||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Delete Generation</DialogTitle>
|
<DialogTitle>{t('history.deleteDialog.title')}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Are you sure you want to delete this generation from "{generationToDelete?.name}"?
|
{t('history.deleteDialog.body', { name: generationToDelete?.name })}
|
||||||
This action cannot be undone.
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
@@ -816,14 +819,14 @@ export function HistoryTable() {
|
|||||||
setGenerationToDelete(null);
|
setGenerationToDelete(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Cancel
|
{t('common.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={handleDeleteConfirm}
|
onClick={handleDeleteConfirm}
|
||||||
disabled={deleteGeneration.isPending}
|
disabled={deleteGeneration.isPending}
|
||||||
>
|
>
|
||||||
{deleteGeneration.isPending ? 'Deleting...' : 'Delete'}
|
{deleteGeneration.isPending ? t('history.deleteDialog.deleting') : t('common.delete')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@@ -832,23 +835,23 @@ export function HistoryTable() {
|
|||||||
<Dialog open={clearFailedDialogOpen} onOpenChange={setClearFailedDialogOpen}>
|
<Dialog open={clearFailedDialogOpen} onOpenChange={setClearFailedDialogOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Clear failed generations</DialogTitle>
|
<DialogTitle>{t('history.clearFailedDialog.title')}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
This will permanently delete {failedCount} failed{' '}
|
{t('history.clearFailedDialog.body', { count: failedCount })}
|
||||||
{failedCount === 1 ? 'generation' : 'generations'} from your history. This cannot be
|
|
||||||
undone.
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setClearFailedDialogOpen(false)}>
|
<Button variant="outline" onClick={() => setClearFailedDialogOpen(false)}>
|
||||||
Cancel
|
{t('common.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={handleClearFailedConfirm}
|
onClick={handleClearFailedConfirm}
|
||||||
disabled={clearFailed.isPending}
|
disabled={clearFailed.isPending}
|
||||||
>
|
>
|
||||||
{clearFailed.isPending ? 'Clearing...' : 'Clear all'}
|
{clearFailed.isPending
|
||||||
|
? t('history.clearFailedDialog.clearing')
|
||||||
|
: t('history.clearFailedDialog.clearAll')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@@ -857,9 +860,9 @@ export function HistoryTable() {
|
|||||||
<Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
|
<Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Import Generation</DialogTitle>
|
<DialogTitle>{t('history.importDialog.title')}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Import the generation from "{selectedFile?.name}". This will add it to your history.
|
{t('history.importDialog.body', { name: selectedFile?.name })}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
@@ -873,13 +876,15 @@ export function HistoryTable() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Cancel
|
{t('common.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleImportConfirm}
|
onClick={handleImportConfirm}
|
||||||
disabled={importGeneration.isPending || !selectedFile}
|
disabled={importGeneration.isPending || !selectedFile}
|
||||||
>
|
>
|
||||||
{importGeneration.isPending ? 'Importing...' : 'Import'}
|
{importGeneration.isPending
|
||||||
|
? t('history.importDialog.importing')
|
||||||
|
: t('history.importDialog.action')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@@ -888,11 +893,8 @@ export function HistoryTable() {
|
|||||||
<Dialog open={effectsDialogOpen} onOpenChange={setEffectsDialogOpen}>
|
<Dialog open={effectsDialogOpen} onOpenChange={setEffectsDialogOpen}>
|
||||||
<DialogContent className="max-w-md">
|
<DialogContent className="max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Apply Effects</DialogTitle>
|
<DialogTitle>{t('history.effectsDialog.title')}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>{t('history.effectsDialog.body')}</DialogDescription>
|
||||||
Configure post-processing effects to apply to this generation. A new version will be
|
|
||||||
created.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{effectsTargetVersions.length > 1 && (
|
{effectsTargetVersions.length > 1 && (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Download, Edit, Sparkles, Trash2 } from 'lucide-react';
|
import { Download, Edit, Sparkles, Trash2 } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@@ -29,6 +30,7 @@ interface ProfileCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ProfileCard({ profile, disabled }: ProfileCardProps) {
|
export function ProfileCard({ profile, disabled }: ProfileCardProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
|
||||||
const deleteProfile = useDeleteProfile();
|
const deleteProfile = useDeleteProfile();
|
||||||
@@ -41,7 +43,6 @@ export function ProfileCard({ profile, disabled }: ProfileCardProps) {
|
|||||||
const isSelected = selectedProfileId === profile.id;
|
const isSelected = selectedProfileId === profile.id;
|
||||||
|
|
||||||
const handleSelect = () => {
|
const handleSelect = () => {
|
||||||
// If disabled but already selected, bounce the selection to re-trigger engine auto-switch
|
|
||||||
if (disabled && isSelected) {
|
if (disabled && isSelected) {
|
||||||
setSelectedProfileId(null);
|
setSelectedProfileId(null);
|
||||||
setTimeout(() => setSelectedProfileId(profile.id), 0);
|
setTimeout(() => setSelectedProfileId(profile.id), 0);
|
||||||
@@ -79,9 +80,10 @@ export function ProfileCard({ profile, disabled }: ProfileCardProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectLabel = isSelected
|
const selectLabel = t(
|
||||||
? `${profile.name}, ${profile.language}. Selected as voice for generation.`
|
isSelected ? 'profiles.card.selectLabelSelected' : 'profiles.card.selectLabel',
|
||||||
: `${profile.name}, ${profile.language}. Select as voice for generation.`;
|
{ name: profile.name, language: profile.language },
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -105,7 +107,7 @@ export function ProfileCard({ profile, disabled }: ProfileCardProps) {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-3 pt-0 flex flex-col flex-1">
|
<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">
|
<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>
|
</p>
|
||||||
<div className="mb-2 flex items-center gap-1.5">
|
<div className="mb-2 flex items-center gap-1.5">
|
||||||
<Badge variant="outline" className="text-xs h-5 px-1.5 text-muted-foreground">
|
<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' && (
|
{profile.voice_type === 'designed' && (
|
||||||
<Badge variant="secondary" className="text-xs h-5 px-1.5">
|
<Badge variant="secondary" className="text-xs h-5 px-1.5">
|
||||||
designed
|
{t('profiles.card.designed')}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{profile.effects_chain && profile.effects_chain.length > 0 && (
|
{profile.effects_chain && profile.effects_chain.length > 0 && (
|
||||||
@@ -130,7 +132,7 @@ export function ProfileCard({ profile, disabled }: ProfileCardProps) {
|
|||||||
icon={Download}
|
icon={Download}
|
||||||
onClick={handleExport}
|
onClick={handleExport}
|
||||||
disabled={exportProfile.isPending}
|
disabled={exportProfile.isPending}
|
||||||
aria-label="Export profile"
|
aria-label={t('profiles.card.export')}
|
||||||
/>
|
/>
|
||||||
<CircleButton
|
<CircleButton
|
||||||
icon={Edit}
|
icon={Edit}
|
||||||
@@ -138,13 +140,13 @@ export function ProfileCard({ profile, disabled }: ProfileCardProps) {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleEdit();
|
handleEdit();
|
||||||
}}
|
}}
|
||||||
aria-label="Edit profile"
|
aria-label={t('profiles.card.edit')}
|
||||||
/>
|
/>
|
||||||
<CircleButton
|
<CircleButton
|
||||||
icon={Trash2}
|
icon={Trash2}
|
||||||
onClick={handleDeleteClick}
|
onClick={handleDeleteClick}
|
||||||
disabled={deleteProfile.isPending}
|
disabled={deleteProfile.isPending}
|
||||||
aria-label="Delete profile"
|
aria-label={t('profiles.card.delete')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -153,21 +155,21 @@ export function ProfileCard({ profile, disabled }: ProfileCardProps) {
|
|||||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Delete Profile</DialogTitle>
|
<DialogTitle>{t('profiles.deleteDialog.title')}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Are you sure you want to delete "{profile.name}"? This action cannot be undone.
|
{t('profiles.deleteDialog.body', { name: profile.name })}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
|
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
|
||||||
Cancel
|
{t('common.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={handleDeleteConfirm}
|
onClick={handleDeleteConfirm}
|
||||||
disabled={deleteProfile.isPending}
|
disabled={deleteProfile.isPending}
|
||||||
>
|
>
|
||||||
{deleteProfile.isPending ? 'Deleting...' : 'Delete'}
|
{deleteProfile.isPending ? t('profiles.deleteDialog.deleting') : t('common.delete')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -21,6 +21,46 @@
|
|||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"updateBadge": "Update"
|
"updateBadge": "Update"
|
||||||
},
|
},
|
||||||
|
"profiles": {
|
||||||
|
"card": {
|
||||||
|
"noDescription": "No description",
|
||||||
|
"designed": "designed",
|
||||||
|
"export": "Export profile",
|
||||||
|
"edit": "Edit profile",
|
||||||
|
"delete": "Delete profile",
|
||||||
|
"selectLabel": "{{name}}, {{language}}. Select as voice for generation.",
|
||||||
|
"selectLabelSelected": "{{name}}, {{language}}. Selected as voice for generation."
|
||||||
|
},
|
||||||
|
"deleteDialog": {
|
||||||
|
"title": "Delete Profile",
|
||||||
|
"body": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
|
||||||
|
"deleting": "Deleting…"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"history": {
|
||||||
|
"deleteDialog": {
|
||||||
|
"title": "Delete Generation",
|
||||||
|
"body": "Are you sure you want to delete this generation from \"{{name}}\"? This action cannot be undone.",
|
||||||
|
"deleting": "Deleting…"
|
||||||
|
},
|
||||||
|
"clearFailedDialog": {
|
||||||
|
"title": "Clear failed generations",
|
||||||
|
"body_one": "This will permanently delete {{count}} failed generation from your history. This cannot be undone.",
|
||||||
|
"body_other": "This will permanently delete {{count}} failed generations from your history. This cannot be undone.",
|
||||||
|
"clearing": "Clearing…",
|
||||||
|
"clearAll": "Clear all"
|
||||||
|
},
|
||||||
|
"importDialog": {
|
||||||
|
"title": "Import Generation",
|
||||||
|
"body": "Import the generation from \"{{name}}\". This will add it to your history.",
|
||||||
|
"importing": "Importing…",
|
||||||
|
"action": "Import"
|
||||||
|
},
|
||||||
|
"effectsDialog": {
|
||||||
|
"title": "Apply Effects",
|
||||||
|
"body": "Configure post-processing effects to apply to this generation. A new version will be created."
|
||||||
|
}
|
||||||
|
},
|
||||||
"generation": {
|
"generation": {
|
||||||
"placeholder": {
|
"placeholder": {
|
||||||
"storyWithEffects": "Generate speech for \"{{name}}\"… (type / for effects)",
|
"storyWithEffects": "Generate speech for \"{{name}}\"… (type / for effects)",
|
||||||
|
|||||||
@@ -21,6 +21,46 @@
|
|||||||
"settings": "设置",
|
"settings": "设置",
|
||||||
"updateBadge": "更新"
|
"updateBadge": "更新"
|
||||||
},
|
},
|
||||||
|
"profiles": {
|
||||||
|
"card": {
|
||||||
|
"noDescription": "无描述",
|
||||||
|
"designed": "设计",
|
||||||
|
"export": "导出声音档案",
|
||||||
|
"edit": "编辑声音档案",
|
||||||
|
"delete": "删除声音档案",
|
||||||
|
"selectLabel": "{{name}},{{language}}。选择用于生成的声音。",
|
||||||
|
"selectLabelSelected": "{{name}},{{language}}。已选为用于生成的声音。"
|
||||||
|
},
|
||||||
|
"deleteDialog": {
|
||||||
|
"title": "删除声音档案",
|
||||||
|
"body": "确定要删除 \"{{name}}\" 吗?此操作不可撤销。",
|
||||||
|
"deleting": "删除中…"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"history": {
|
||||||
|
"deleteDialog": {
|
||||||
|
"title": "删除生成",
|
||||||
|
"body": "确定要删除来自 \"{{name}}\" 的这次生成吗?此操作不可撤销。",
|
||||||
|
"deleting": "删除中…"
|
||||||
|
},
|
||||||
|
"clearFailedDialog": {
|
||||||
|
"title": "清除失败的生成",
|
||||||
|
"body_one": "这将从历史记录中永久删除 {{count}} 条失败的生成。此操作不可撤销。",
|
||||||
|
"body_other": "这将从历史记录中永久删除 {{count}} 条失败的生成。此操作不可撤销。",
|
||||||
|
"clearing": "清除中…",
|
||||||
|
"clearAll": "全部清除"
|
||||||
|
},
|
||||||
|
"importDialog": {
|
||||||
|
"title": "导入生成",
|
||||||
|
"body": "从 \"{{name}}\" 导入生成。这将添加到您的历史记录中。",
|
||||||
|
"importing": "导入中…",
|
||||||
|
"action": "导入"
|
||||||
|
},
|
||||||
|
"effectsDialog": {
|
||||||
|
"title": "应用效果",
|
||||||
|
"body": "配置应用于此次生成的后处理效果。将会创建一个新版本。"
|
||||||
|
}
|
||||||
|
},
|
||||||
"generation": {
|
"generation": {
|
||||||
"placeholder": {
|
"placeholder": {
|
||||||
"storyWithEffects": "为 \"{{name}}\" 生成语音… (输入 / 使用效果)",
|
"storyWithEffects": "为 \"{{name}}\" 生成语音… (输入 / 使用效果)",
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { formatDistance } from 'date-fns';
|
import { formatDistance } from 'date-fns';
|
||||||
|
import { zhCN } from 'date-fns/locale';
|
||||||
|
import i18n from '@/i18n';
|
||||||
|
|
||||||
export function formatDuration(seconds: number): string {
|
export function formatDuration(seconds: number): string {
|
||||||
const mins = Math.floor(seconds / 60);
|
const mins = Math.floor(seconds / 60);
|
||||||
@@ -6,15 +8,17 @@ export function formatDuration(seconds: number): string {
|
|||||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getDateLocale() {
|
||||||
|
if (i18n.language === 'zh-CN') return zhCN;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function formatDate(date: string | Date): string {
|
export function formatDate(date: string | Date): string {
|
||||||
// Parse the date string - if it doesn't have timezone info, treat it as UTC
|
|
||||||
let dateObj: Date;
|
let dateObj: Date;
|
||||||
if (typeof date === 'string') {
|
if (typeof date === 'string') {
|
||||||
// If the string doesn't end with Z or have timezone offset, assume it's UTC
|
|
||||||
const dateStr = date.trim();
|
const dateStr = date.trim();
|
||||||
if (!dateStr.includes('Z') && !dateStr.match(/[+-]\d{2}:\d{2}$/)) {
|
if (!dateStr.includes('Z') && !dateStr.match(/[+-]\d{2}:\d{2}$/)) {
|
||||||
// No timezone info, treat as UTC
|
dateObj = new Date(`${dateStr}Z`);
|
||||||
dateObj = new Date(dateStr + 'Z');
|
|
||||||
} else {
|
} else {
|
||||||
dateObj = new Date(dateStr);
|
dateObj = new Date(dateStr);
|
||||||
}
|
}
|
||||||
@@ -22,7 +26,10 @@ export function formatDate(date: string | Date): string {
|
|||||||
dateObj = date;
|
dateObj = date;
|
||||||
}
|
}
|
||||||
|
|
||||||
return formatDistance(dateObj, new Date(), { addSuffix: true }).replace(/^about /i, '');
|
return formatDistance(dateObj, new Date(), {
|
||||||
|
addSuffix: true,
|
||||||
|
locale: getDateLocale(),
|
||||||
|
}).replace(/^about /i, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
const ENGINE_DISPLAY_NAMES: Record<string, string> = {
|
const ENGINE_DISPLAY_NAMES: Record<string, string> = {
|
||||||
|
|||||||
Reference in New Issue
Block a user