feat: translation (#389)

This commit is contained in:
Cody Tseng
2025-06-23 23:52:21 +08:00
committed by GitHub
parent e2e115ebeb
commit df9066eae0
43 changed files with 1466 additions and 47 deletions

View File

@@ -1,7 +1,13 @@
import AboutInfoDialog from '@/components/AboutInfoDialog'
import Donation from '@/components/Donation'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { toGeneralSettings, toPostSettings, toRelaySettings, toWallet } from '@/lib/link'
import {
toGeneralSettings,
toPostSettings,
toRelaySettings,
toTranslation,
toWallet
} from '@/lib/link'
import { cn } from '@/lib/utils'
import { useSecondaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
@@ -11,6 +17,7 @@ import {
Copy,
Info,
KeyRound,
Languages,
PencilLine,
Server,
Settings2,
@@ -42,6 +49,13 @@ const SettingsPage = forwardRef(({ index }: { index?: number }, ref) => {
</div>
<ChevronRight />
</SettingItem>
<SettingItem className="clickable" onClick={() => push(toTranslation())}>
<div className="flex items-center gap-4">
<Languages />
<div>{t('Translation')}</div>
</div>
<ChevronRight />
</SettingItem>
<SettingItem className="clickable" onClick={() => push(toWallet())}>
<div className="flex items-center gap-4">
<Wallet />

View File

@@ -0,0 +1,75 @@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { JUMBLE_API_BASE_URL } from '@/constants'
import { useNostr } from '@/providers/NostrProvider'
import { Check, Copy, Eye, EyeOff } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useJumbleTranslateAccount } from './JumbleTranslateAccountProvider'
import RegenerateApiKeyButton from './RegenerateApiKeyButton'
import TopUp from './TopUp'
export function AccountInfo() {
const { t } = useTranslation()
const { pubkey, startLogin } = useNostr()
const { account } = useJumbleTranslateAccount()
const [showApiKey, setShowApiKey] = useState(false)
const [copied, setCopied] = useState(false)
if (!pubkey) {
return (
<div className="w-full flex justify-center">
<Button onClick={() => startLogin()}>{t('Login')}</Button>
</div>
)
}
return (
<div className="space-y-4">
{/* Balance display in characters */}
<div className="space-y-2">
<p className="font-medium">{t('Balance')}</p>
<div className="flex items-baseline gap-2">
<p className="text-3xl font-bold">{account?.balance.toLocaleString() ?? '0'}</p>
<p className="text-muted-foreground">{t('characters')}</p>
</div>
</div>
{/* API Key section with visibility toggle and copy functionality */}
<div className="space-y-2">
<p className="font-medium">API key</p>
<div className="flex items-center gap-2">
<Input
type={showApiKey ? 'text' : 'password'}
value={account?.api_key ?? ''}
readOnly
className="font-mono flex-1 max-w-fit"
/>
<Button variant="outline" onClick={() => setShowApiKey(!showApiKey)}>
{showApiKey ? <Eye /> : <EyeOff />}
</Button>
<Button
variant="outline"
disabled={!account?.api_key}
onClick={() => {
if (!account?.api_key) return
navigator.clipboard.writeText(account.api_key)
setCopied(true)
setTimeout(() => setCopied(false), 4000)
}}
>
{copied ? <Check /> : <Copy />}
</Button>
<RegenerateApiKeyButton />
</div>
<p className="text-sm text-muted-foreground select-text">
{t('jumbleTranslateApiKeyDescription', {
serviceUrl: new URL('/v1/translation', JUMBLE_API_BASE_URL).toString()
})}
</p>
</div>
<TopUp />
<div className="h-40" />
</div>
)
}

View File

@@ -0,0 +1,87 @@
import { useNostr } from '@/providers/NostrProvider'
import { useTranslationService } from '@/providers/TranslationServiceProvider'
import { TTranslationAccount } from '@/types'
import { createContext, useContext, useEffect, useState } from 'react'
import { toast } from 'sonner'
type TJumbleTranslateAccountContext = {
account: TTranslationAccount | null
getAccount: () => Promise<void>
regenerateApiKey: () => Promise<void>
}
export const JumbleTranslateAccountContext = createContext<
TJumbleTranslateAccountContext | undefined
>(undefined)
export const useJumbleTranslateAccount = () => {
const context = useContext(JumbleTranslateAccountContext)
if (!context) {
throw new Error(
'useJumbleTranslateAccount must be used within a JumbleTranslateAccountProvider'
)
}
return context
}
export function JumbleTranslateAccountProvider({ children }: { children: React.ReactNode }) {
const { pubkey } = useNostr()
const { getAccount: _getAccount, regenerateApiKey: _regenerateApiKey } = useTranslationService()
const [account, setAccount] = useState<TTranslationAccount | null>(null)
useEffect(() => {
setAccount(null)
if (!pubkey) return
setTimeout(() => {
getAccount()
}, 100)
}, [pubkey])
const regenerateApiKey = async (): Promise<void> => {
try {
if (!account) {
await getAccount()
}
const newApiKey = await _regenerateApiKey()
if (newApiKey) {
setAccount((prev) => {
if (!prev) return prev
return {
...prev,
api_key: newApiKey
}
})
}
} catch (error) {
toast.error(
'Failed to regenerate Jumble translation API key: ' +
(error instanceof Error
? error.message
: 'An error occurred while regenerating the API key')
)
setAccount(null)
}
}
const getAccount = async (): Promise<void> => {
try {
const data = await _getAccount()
if (data) {
setAccount(data)
}
} catch (error) {
toast.error(
'Failed to fetch Jumble translation account: ' +
(error instanceof Error ? error.message : 'An error occurred while fetching the account')
)
setAccount(null)
}
}
return (
<JumbleTranslateAccountContext.Provider value={{ account, getAccount, regenerateApiKey }}>
{children}
</JumbleTranslateAccountContext.Provider>
)
}

View File

@@ -0,0 +1,67 @@
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger
} from '@/components/ui/dialog'
import { Loader, RotateCcw } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useJumbleTranslateAccount } from './JumbleTranslateAccountProvider'
export default function RegenerateApiKeyButton() {
const { t } = useTranslation()
const { account, regenerateApiKey } = useJumbleTranslateAccount()
const [resettingApiKey, setResettingApiKey] = useState(false)
const [showResetDialog, setShowResetDialog] = useState(false)
const handleRegenerateApiKey = async () => {
if (resettingApiKey || !account) return
setResettingApiKey(true)
await regenerateApiKey()
setShowResetDialog(false)
setResettingApiKey(false)
}
return (
<Dialog open={showResetDialog} onOpenChange={setShowResetDialog}>
<DialogTrigger asChild>
<Button variant="outline" disabled={!account?.api_key}>
<RotateCcw />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('Reset API key')}</DialogTitle>
<DialogDescription>
{t('Are you sure you want to reset your API key? This action cannot be undone.')}
<br />
<br />
<strong>{t('Warning')}:</strong>{' '}
{t(
'Your current API key will become invalid immediately, and any applications using it will stop working until you update them with the new key.'
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowResetDialog(false)}
disabled={resettingApiKey}
>
{t('Cancel')}
</Button>
<Button variant="destructive" onClick={handleRegenerateApiKey} disabled={resettingApiKey}>
{resettingApiKey && <Loader className="animate-spin" />}
{t('Reset API key')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,164 @@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import transaction from '@/services/transaction.service'
import { closeModal, launchPaymentModal } from '@getalby/bitcoin-connect-react'
import { Loader } from 'lucide-react'
import { useState } from 'react'
import { toast } from 'sonner'
import { useJumbleTranslateAccount } from './JumbleTranslateAccountProvider'
import { useTranslation } from 'react-i18next'
export default function TopUp() {
const { t } = useTranslation()
const { pubkey } = useNostr()
const { getAccount } = useJumbleTranslateAccount()
const [topUpLoading, setTopUpLoading] = useState(false)
const [topUpAmount, setTopUpAmount] = useState(1000)
const [selectedAmount, setSelectedAmount] = useState<number | null>(1000)
const presetAmounts = [
{ amount: 1_000, text: '1k' },
{ amount: 5_000, text: '5k' },
{ amount: 10_000, text: '10k' },
{ amount: 25_000, text: '25k' },
{ amount: 50_000, text: '50k' },
{ amount: 100_000, text: '100k' }
]
const charactersPerUnit = 100 // 1 unit = 100 characters
const calculateCharacters = (amount: number) => {
return amount * charactersPerUnit
}
const handlePresetClick = (amount: number) => {
setSelectedAmount(amount)
setTopUpAmount(amount)
}
const handleInputChange = (value: string) => {
const numValue = parseInt(value) || 0
setTopUpAmount(numValue)
setSelectedAmount(numValue >= 1000 ? numValue : null)
}
const handleTopUp = async (amount: number | null) => {
if (topUpLoading || !pubkey || !amount || amount < 1000) return
setTopUpLoading(true)
try {
const { transactionId, invoiceId } = await transaction.createTransaction(pubkey, amount)
let checkPaymentInterval: ReturnType<typeof setInterval> | undefined = undefined
const { setPaid } = launchPaymentModal({
invoice: invoiceId,
onCancelled: () => {
clearInterval(checkPaymentInterval)
setTopUpLoading(false)
}
})
let failedCount = 0
checkPaymentInterval = setInterval(async () => {
try {
const { state } = await transaction.checkTransaction(transactionId)
if (state === 'pending') return
clearInterval(checkPaymentInterval)
setTopUpLoading(false)
if (state === 'settled') {
setPaid({ preimage: '' }) // Preimage is not returned, but we can assume payment is successful
getAccount() // Refresh account balance
} else {
closeModal()
toast.error('The invoice has expired or the payment was not successful')
}
} catch (err) {
failedCount++
if (failedCount <= 3) return
clearInterval(checkPaymentInterval)
setTopUpLoading(false)
toast.error(
'Top up failed: ' +
(err instanceof Error ? err.message : 'An error occurred while topping up')
)
}
}, 2000)
} catch (err) {
setTopUpLoading(false)
toast.error(
'Top up failed: ' +
(err instanceof Error ? err.message : 'An error occurred while topping up')
)
}
}
return (
<div className="space-y-4">
<p className="font-medium">{t('Top up')}</p>
{/* Preset amounts */}
<div className="grid grid-cols-2 gap-2">
{presetAmounts.map(({ amount, text }) => (
<Button
key={amount}
variant="outline"
onClick={() => handlePresetClick(amount)}
className={cn(
'flex flex-col h-auto py-3 hover:bg-primary/10',
selectedAmount === amount && 'border border-primary bg-primary/10'
)}
>
<span className="text-lg font-semibold">
{text} {t('sats')}
</span>
<span className="text-sm text-muted-foreground">
{calculateCharacters(amount).toLocaleString()} {t('characters')}
</span>
</Button>
))}
</div>
{/* Custom amount input */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Input
type="number"
placeholder="Custom amount"
value={topUpAmount}
onChange={(e) => handleInputChange(e.target.value)}
min={1000}
step={1000}
className="w-40"
/>
<span className="text-sm text-muted-foreground">{t('sats')}</span>
</div>
{selectedAmount && selectedAmount >= 1000 && (
<p className="text-sm text-muted-foreground">
{t('Will receive: {n} characters', {
n: calculateCharacters(selectedAmount).toLocaleString()
})}
</p>
)}
</div>
<Button
className="w-full"
disabled={topUpLoading || !selectedAmount || selectedAmount < 1000}
onClick={() => handleTopUp(selectedAmount)}
>
{topUpLoading && <Loader className="animate-spin" />}
{selectedAmount && selectedAmount >= 1000
? t('Top up {n} sats', {
n: selectedAmount?.toLocaleString()
})
: t('Minimum top up is {n} sats', {
n: new Number(1000).toLocaleString()
})}
</Button>
</div>
)
}

View File

@@ -0,0 +1,10 @@
import { AccountInfo } from './AccountInfo'
import { JumbleTranslateAccountProvider } from './JumbleTranslateAccountProvider'
export default function JumbleTranslate() {
return (
<JumbleTranslateAccountProvider>
<AccountInfo />
</JumbleTranslateAccountProvider>
)
}

View File

@@ -0,0 +1,59 @@
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useTranslationService } from '@/providers/TranslationServiceProvider'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
export default function LibreTranslate() {
const { t } = useTranslation()
const { config, updateConfig } = useTranslationService()
const [server, setServer] = useState(
config.service === 'libre_translate' ? (config.server ?? '') : ''
)
const [apiKey, setApiKey] = useState(
config.service === 'libre_translate' ? (config.api_key ?? '') : ''
)
const initialized = useRef(false)
useEffect(() => {
if (!initialized.current) {
initialized.current = true
return
}
updateConfig({
service: 'libre_translate',
server,
api_key: apiKey
})
}, [server, apiKey])
return (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="libre-translate-server" className="text-base">
{t('Service address')}
</Label>
<Input
id="libre-translate-server"
type="text"
value={server}
onChange={(e) => setServer(e.target.value)}
placeholder="Enter server address"
/>
</div>
<div className="space-y-2">
<Label htmlFor="libre-translate-api-key" className="text-base">
API key
</Label>
<Input
id="libre-translate-api-key"
type="text"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="Enter API Key"
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,74 @@
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { LocalizedLanguageNames } from '@/i18n'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { useTranslationService } from '@/providers/TranslationServiceProvider'
import { TLanguage } from '@/types'
import { forwardRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import JumbleTranslate from './JumbleTranslate'
import LibreTranslate from './LibreTranslate'
const TranslationPage = forwardRef(({ index }: { index?: number }, ref) => {
const { t, i18n } = useTranslation()
const { config, updateConfig } = useTranslationService()
const [language, setLanguage] = useState<TLanguage>(i18n.language as TLanguage)
const handleLanguageChange = (value: TLanguage) => {
i18n.changeLanguage(value)
setLanguage(value)
}
return (
<SecondaryPageLayout ref={ref} index={index} title={t('Translation')}>
<div className="px-4 pt-2 space-y-4">
<div className="space-y-2">
<Label htmlFor="languages" className="text-base font-medium">
{t('Languages')}
</Label>
<Select defaultValue="en" value={language} onValueChange={handleLanguageChange}>
<SelectTrigger id="languages" className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(LocalizedLanguageNames).map(([key, value]) => (
<SelectItem key={key} value={key}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="translation-service-select" className="text-base font-medium">
{t('Service')}
</Label>
<Select
defaultValue={config.service}
value={config.service}
onValueChange={(newService) => {
updateConfig({ service: newService as 'jumble' | 'libre_translate' })
}}
>
<SelectTrigger id="translation-service-select" className="w-[180px]">
<SelectValue placeholder={t('Select Translation Service')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="jumble">Jumble</SelectItem>
<SelectItem value="libre_translate">LibreTranslate</SelectItem>
</SelectContent>
</Select>
</div>
{config.service === 'jumble' ? <JumbleTranslate /> : <LibreTranslate />}
</div>
</SecondaryPageLayout>
)
})
TranslationPage.displayName = 'TranslationPage'
export default TranslationPage