feat: translation (#389)
This commit is contained in:
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
164
src/pages/secondary/TranslationPage/JumbleTranslate/TopUp.tsx
Normal file
164
src/pages/secondary/TranslationPage/JumbleTranslate/TopUp.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { AccountInfo } from './AccountInfo'
|
||||
import { JumbleTranslateAccountProvider } from './JumbleTranslateAccountProvider'
|
||||
|
||||
export default function JumbleTranslate() {
|
||||
return (
|
||||
<JumbleTranslateAccountProvider>
|
||||
<AccountInfo />
|
||||
</JumbleTranslateAccountProvider>
|
||||
)
|
||||
}
|
||||
59
src/pages/secondary/TranslationPage/LibreTranslate/index.tsx
Normal file
59
src/pages/secondary/TranslationPage/LibreTranslate/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
74
src/pages/secondary/TranslationPage/index.tsx
Normal file
74
src/pages/secondary/TranslationPage/index.tsx
Normal 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
|
||||
Reference in New Issue
Block a user