27 changed files with 45 additions and 1229 deletions
@ -1,94 +0,0 @@ |
|||||||
import { ExtendedKind } from '@/constants' |
|
||||||
import { useTranslatedEvent } from '@/hooks' |
|
||||||
import { toTranslation } from '@/lib/link' |
|
||||||
import { cn, detectLanguage } from '@/lib/utils' |
|
||||||
import { useSecondaryPage } from '@/PageManager' |
|
||||||
import { useTranslationService } from '@/providers/TranslationServiceProvider' |
|
||||||
import { Languages, Loader } from 'lucide-react' |
|
||||||
import { Event, kinds } from 'nostr-tools' |
|
||||||
import { useMemo, useState } from 'react' |
|
||||||
import { useTranslation } from 'react-i18next' |
|
||||||
import { toast } from 'sonner' |
|
||||||
|
|
||||||
export default function TranslateButton({ |
|
||||||
event, |
|
||||||
className |
|
||||||
}: { |
|
||||||
event: Event |
|
||||||
className?: string |
|
||||||
}) { |
|
||||||
const { i18n } = useTranslation() |
|
||||||
const { push } = useSecondaryPage() |
|
||||||
const { translateEvent, showOriginalEvent } = useTranslationService() |
|
||||||
const [translating, setTranslating] = useState(false) |
|
||||||
const translatedEvent = useTranslatedEvent(event.id) |
|
||||||
const supported = useMemo( |
|
||||||
() => |
|
||||||
[ |
|
||||||
kinds.ShortTextNote, |
|
||||||
kinds.Highlights, |
|
||||||
ExtendedKind.COMMENT, |
|
||||||
ExtendedKind.PICTURE, |
|
||||||
ExtendedKind.POLL, |
|
||||||
ExtendedKind.RELAY_REVIEW |
|
||||||
].includes(event.kind), |
|
||||||
[event] |
|
||||||
) |
|
||||||
|
|
||||||
const needTranslation = useMemo(() => { |
|
||||||
const detected = detectLanguage(event.content) |
|
||||||
if (!detected) return false |
|
||||||
if (detected === 'und') return true |
|
||||||
return !i18n.language.startsWith(detected) |
|
||||||
}, [event, i18n.language]) |
|
||||||
|
|
||||||
if (!supported || !needTranslation) { |
|
||||||
return null |
|
||||||
} |
|
||||||
|
|
||||||
const handleTranslate = async () => { |
|
||||||
if (translating) return |
|
||||||
|
|
||||||
setTranslating(true) |
|
||||||
await translateEvent(event) |
|
||||||
.catch((error) => { |
|
||||||
toast.error( |
|
||||||
'Translation failed: ' + (error.message || 'An error occurred while translating the note') |
|
||||||
) |
|
||||||
if (error.message === 'Insufficient balance.') { |
|
||||||
push(toTranslation()) |
|
||||||
} |
|
||||||
}) |
|
||||||
.finally(() => { |
|
||||||
setTranslating(false) |
|
||||||
}) |
|
||||||
} |
|
||||||
|
|
||||||
const showOriginal = () => { |
|
||||||
showOriginalEvent(event.id) |
|
||||||
} |
|
||||||
|
|
||||||
return ( |
|
||||||
<button |
|
||||||
className={cn( |
|
||||||
'flex items-center text-muted-foreground hover:text-pink-400 px-2 py-1 h-full disabled:text-muted-foreground [&_svg]:size-4 [&_svg]:shrink-0 transition-colors', |
|
||||||
className |
|
||||||
)} |
|
||||||
disabled={translating} |
|
||||||
onClick={(e) => { |
|
||||||
e.stopPropagation() |
|
||||||
if (translatedEvent) { |
|
||||||
showOriginal() |
|
||||||
} else { |
|
||||||
handleTranslate() |
|
||||||
} |
|
||||||
}} |
|
||||||
> |
|
||||||
{translating ? ( |
|
||||||
<Loader className="animate-spin" /> |
|
||||||
) : ( |
|
||||||
<Languages className={translatedEvent ? 'text-pink-400 hover:text-pink-400/60' : ''} /> |
|
||||||
)} |
|
||||||
</button> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,21 +0,0 @@ |
|||||||
import { useTranslationService } from '@/providers/TranslationServiceProvider' |
|
||||||
import { Event } from 'nostr-tools' |
|
||||||
import { useEffect, useMemo, useState } from 'react' |
|
||||||
|
|
||||||
export function useTranslatedEvent(eventId?: string) { |
|
||||||
const { translatedEventIdSet, getTranslatedEvent } = useTranslationService() |
|
||||||
const translated = useMemo(() => { |
|
||||||
return eventId ? translatedEventIdSet.has(eventId) : false |
|
||||||
}, [eventId, translatedEventIdSet]) |
|
||||||
const [translatedEvent, setTranslatedEvent] = useState<Event | null>(null) |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
if (translated && eventId) { |
|
||||||
setTranslatedEvent(getTranslatedEvent(eventId)) |
|
||||||
} else { |
|
||||||
setTranslatedEvent(null) |
|
||||||
} |
|
||||||
}, [translated, eventId]) |
|
||||||
|
|
||||||
return translatedEvent |
|
||||||
} |
|
||||||
@ -1,75 +0,0 @@ |
|||||||
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> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,87 +0,0 @@ |
|||||||
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> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,67 +0,0 @@ |
|||||||
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> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,164 +0,0 @@ |
|||||||
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> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,10 +0,0 @@ |
|||||||
import { AccountInfo } from './AccountInfo' |
|
||||||
import { JumbleTranslateAccountProvider } from './JumbleTranslateAccountProvider' |
|
||||||
|
|
||||||
export default function JumbleTranslate() { |
|
||||||
return ( |
|
||||||
<JumbleTranslateAccountProvider> |
|
||||||
<AccountInfo /> |
|
||||||
</JumbleTranslateAccountProvider> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,59 +0,0 @@ |
|||||||
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> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,74 +1,23 @@ |
|||||||
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 SecondaryPageLayout from '@/layouts/SecondaryPageLayout' |
||||||
import { useTranslationService } from '@/providers/TranslationServiceProvider' |
import { forwardRef } from 'react' |
||||||
import { TLanguage } from '@/types' |
|
||||||
import { forwardRef, useState } from 'react' |
|
||||||
import { useTranslation } from 'react-i18next' |
import { useTranslation } from 'react-i18next' |
||||||
import JumbleTranslate from './JumbleTranslate' |
|
||||||
import LibreTranslate from './LibreTranslate' |
|
||||||
|
|
||||||
const TranslationPage = forwardRef(({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { |
const TranslationPage = forwardRef( |
||||||
const { t, i18n } = useTranslation() |
({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { |
||||||
const { config, updateConfig } = useTranslationService() |
const { t } = useTranslation() |
||||||
const [language, setLanguage] = useState<TLanguage>(i18n.language as TLanguage) |
|
||||||
|
|
||||||
const handleLanguageChange = (value: TLanguage) => { |
return ( |
||||||
i18n.changeLanguage(value) |
<SecondaryPageLayout ref={ref} index={index} title={hideTitlebar ? undefined : t('Translation')}> |
||||||
setLanguage(value) |
<div className="px-4 pt-3 space-y-4"> |
||||||
} |
<p className="text-muted-foreground"> |
||||||
|
{t( |
||||||
return ( |
'To translate notes and other content, use your browser’s built-in translation. For example: right-click the page and choose “Translate to…”, or use the translate icon in the address bar.' |
||||||
<SecondaryPageLayout ref={ref} index={index} title={hideTitlebar ? undefined : t('Translation')}> |
)} |
||||||
<div className="px-4 pt-3 space-y-4"> |
</p> |
||||||
<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> |
||||||
<div className="space-y-2"> |
</SecondaryPageLayout> |
||||||
<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' |
TranslationPage.displayName = 'TranslationPage' |
||||||
export default TranslationPage |
export default TranslationPage |
||||||
|
|||||||
@ -1,235 +0,0 @@ |
|||||||
import { ExtendedKind } from '@/constants' |
|
||||||
import { getPollMetadataFromEvent } from '@/lib/event-metadata' |
|
||||||
import libreTranslate from '@/services/libre-translate.service' |
|
||||||
import storage from '@/services/local-storage.service' |
|
||||||
import translation from '@/services/translation.service' |
|
||||||
import { TTranslationAccount, TTranslationServiceConfig } from '@/types' |
|
||||||
import { Event, kinds } from 'nostr-tools' |
|
||||||
import { createContext, useContext, useEffect, useState } from 'react' |
|
||||||
import { useTranslation } from 'react-i18next' |
|
||||||
import { useNostr } from './NostrProvider' |
|
||||||
|
|
||||||
const translatedEventCache: Map<string, Event> = new Map() |
|
||||||
const translatedTextCache: Map<string, string> = new Map() |
|
||||||
|
|
||||||
type TTranslationServiceContext = { |
|
||||||
config: TTranslationServiceConfig |
|
||||||
translatedEventIdSet: Set<string> |
|
||||||
translateText: (text: string) => Promise<string> |
|
||||||
translateEvent: (event: Event) => Promise<Event | void> |
|
||||||
getTranslatedEvent: (eventId: string) => Event | null |
|
||||||
showOriginalEvent: (eventId: string) => void |
|
||||||
getAccount: () => Promise<TTranslationAccount | void> |
|
||||||
regenerateApiKey: () => Promise<string | undefined> |
|
||||||
updateConfig: (newConfig: TTranslationServiceConfig) => void |
|
||||||
} |
|
||||||
|
|
||||||
const TranslationServiceContext = createContext<TTranslationServiceContext | undefined>(undefined) |
|
||||||
|
|
||||||
export const useTranslationService = () => { |
|
||||||
const context = useContext(TranslationServiceContext) |
|
||||||
if (!context) { |
|
||||||
throw new Error('useTranslation must be used within a TranslationProvider') |
|
||||||
} |
|
||||||
return context |
|
||||||
} |
|
||||||
|
|
||||||
export function TranslationServiceProvider({ children }: { children: React.ReactNode }) { |
|
||||||
const { i18n } = useTranslation() |
|
||||||
const [config, setConfig] = useState<TTranslationServiceConfig>({ service: 'jumble' }) |
|
||||||
const { pubkey, startLogin } = useNostr() |
|
||||||
const [translatedEventIdSet, setTranslatedEventIdSet] = useState<Set<string>>(new Set()) |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
translation.changeCurrentPubkey(pubkey) |
|
||||||
const config = storage.getTranslationServiceConfig(pubkey) |
|
||||||
setConfig(config) |
|
||||||
}, [pubkey]) |
|
||||||
|
|
||||||
const getAccount = async (): Promise<TTranslationAccount | void> => { |
|
||||||
if (config.service !== 'jumble') return |
|
||||||
if (!pubkey) { |
|
||||||
startLogin() |
|
||||||
return |
|
||||||
} |
|
||||||
return await translation.getAccount() |
|
||||||
} |
|
||||||
|
|
||||||
const regenerateApiKey = async (): Promise<string | undefined> => { |
|
||||||
if (config.service !== 'jumble') return |
|
||||||
if (!pubkey) { |
|
||||||
startLogin() |
|
||||||
return |
|
||||||
} |
|
||||||
return await translation.regenerateApiKey() |
|
||||||
} |
|
||||||
|
|
||||||
const getTranslatedEvent = (eventId: string): Event | null => { |
|
||||||
const target = i18n.language |
|
||||||
const cacheKey = target + '_' + eventId |
|
||||||
return translatedEventCache.get(cacheKey) ?? null |
|
||||||
} |
|
||||||
|
|
||||||
const translate = async (text: string, target: string): Promise<string> => { |
|
||||||
if (config.service === 'jumble') { |
|
||||||
return await translation.translate(text, target) |
|
||||||
} else { |
|
||||||
return await libreTranslate.translate(text, target, config.server, config.api_key) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
const translateText = async (text: string): Promise<string> => { |
|
||||||
if (!text) { |
|
||||||
return text |
|
||||||
} |
|
||||||
|
|
||||||
const target = i18n.language |
|
||||||
const cacheKey = target + '_' + text |
|
||||||
const cache = translatedTextCache.get(cacheKey) |
|
||||||
if (cache) { |
|
||||||
return cache |
|
||||||
} |
|
||||||
|
|
||||||
const translatedText = await translate(text, target) |
|
||||||
translatedTextCache.set(cacheKey, translatedText) |
|
||||||
return translatedText |
|
||||||
} |
|
||||||
|
|
||||||
const translateHighlightEvent = async (event: Event): Promise<Event> => { |
|
||||||
const target = i18n.language |
|
||||||
const comment = event.tags.find((tag) => tag[0] === 'comment')?.[1] |
|
||||||
|
|
||||||
const texts = { |
|
||||||
content: event.content, |
|
||||||
comment |
|
||||||
} |
|
||||||
const joinedText = joinTexts(texts) |
|
||||||
if (!joinedText) return event |
|
||||||
|
|
||||||
const translatedText = await translate(joinedText, target) |
|
||||||
const translatedTexts = splitTranslatedText(translatedText) |
|
||||||
return { |
|
||||||
...event, |
|
||||||
content: translatedTexts.content ?? event.content, |
|
||||||
tags: event.tags.map((tag) => |
|
||||||
tag[0] === 'comment' ? ['comment', translatedTexts.comment ?? tag[1]] : tag |
|
||||||
) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
const translatePollEvent = async (event: Event): Promise<Event> => { |
|
||||||
const target = i18n.language |
|
||||||
const pollMetadata = getPollMetadataFromEvent(event) |
|
||||||
|
|
||||||
const texts: Record<string, string> = { |
|
||||||
question: event.content, |
|
||||||
...pollMetadata?.options.reduce( |
|
||||||
(acc, option) => { |
|
||||||
acc[option.id] = option.label |
|
||||||
return acc |
|
||||||
}, |
|
||||||
{} as Record<string, string> |
|
||||||
) |
|
||||||
} |
|
||||||
const joinedText = joinTexts(texts) |
|
||||||
if (!joinedText) return event |
|
||||||
|
|
||||||
const translatedText = await translate(joinedText, target) |
|
||||||
const translatedTexts = splitTranslatedText(translatedText) |
|
||||||
return { |
|
||||||
...event, |
|
||||||
content: translatedTexts.question ?? '', |
|
||||||
tags: event.tags.map((tag) => |
|
||||||
tag[0] === 'option' ? ['option', tag[1], translatedTexts[tag[1]] ?? tag[2]] : tag |
|
||||||
) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
const translateEvent = async (event: Event): Promise<Event | void> => { |
|
||||||
if (config.service === 'jumble' && !pubkey) { |
|
||||||
startLogin() |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
const target = i18n.language |
|
||||||
const cacheKey = target + '_' + event.id |
|
||||||
const cache = translatedEventCache.get(cacheKey) |
|
||||||
if (cache) { |
|
||||||
setTranslatedEventIdSet((prev) => new Set(prev.add(event.id))) |
|
||||||
return cache |
|
||||||
} |
|
||||||
|
|
||||||
let translatedEvent: Event | undefined |
|
||||||
if (event.kind === kinds.Highlights) { |
|
||||||
translatedEvent = await translateHighlightEvent(event) |
|
||||||
} else if (event.kind === ExtendedKind.POLL) { |
|
||||||
translatedEvent = await translatePollEvent(event) |
|
||||||
} else { |
|
||||||
const translatedText = await translate(event.content, target) |
|
||||||
if (!translatedText) { |
|
||||||
return |
|
||||||
} |
|
||||||
translatedEvent = { ...event, content: translatedText } |
|
||||||
} |
|
||||||
|
|
||||||
translatedEventCache.set(cacheKey, translatedEvent) |
|
||||||
setTranslatedEventIdSet((prev) => new Set(prev.add(event.id))) |
|
||||||
return translatedEvent |
|
||||||
} |
|
||||||
|
|
||||||
const showOriginalEvent = (eventId: string) => { |
|
||||||
setTranslatedEventIdSet((prev) => { |
|
||||||
const newSet = new Set(prev) |
|
||||||
newSet.delete(eventId) |
|
||||||
return newSet |
|
||||||
}) |
|
||||||
} |
|
||||||
|
|
||||||
const updateConfig = (newConfig: TTranslationServiceConfig) => { |
|
||||||
setConfig(newConfig) |
|
||||||
storage.setTranslationServiceConfig(newConfig, pubkey) |
|
||||||
} |
|
||||||
|
|
||||||
return ( |
|
||||||
<TranslationServiceContext.Provider |
|
||||||
value={{ |
|
||||||
config, |
|
||||||
translatedEventIdSet, |
|
||||||
getAccount, |
|
||||||
regenerateApiKey, |
|
||||||
translateText, |
|
||||||
translateEvent, |
|
||||||
getTranslatedEvent, |
|
||||||
showOriginalEvent, |
|
||||||
updateConfig |
|
||||||
}} |
|
||||||
> |
|
||||||
{children} |
|
||||||
</TranslationServiceContext.Provider> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
function joinTexts(texts: Record<string, string | undefined>): string { |
|
||||||
return ( |
|
||||||
Object.entries(texts).filter(([, content]) => content && content.trim() !== '') as [ |
|
||||||
string, |
|
||||||
string |
|
||||||
][] |
|
||||||
) |
|
||||||
.map(([key, content]) => `=== ${key} ===\n${content.trim()}\n=== ${key} ===`) |
|
||||||
.join('\n\n') |
|
||||||
} |
|
||||||
|
|
||||||
function splitTranslatedText(translated: string) { |
|
||||||
const regex = /=== (.+?) ===\n([\s\S]*?)\n=== \1 ===/g |
|
||||||
const results: Record<string, string | undefined> = {} |
|
||||||
|
|
||||||
let match: RegExpExecArray | null |
|
||||||
while ((match = regex.exec(translated)) !== null) { |
|
||||||
const key = match[1].trim() |
|
||||||
const content = match[2].trim() |
|
||||||
results[key] = content |
|
||||||
} |
|
||||||
|
|
||||||
return results |
|
||||||
} |
|
||||||
@ -1,42 +0,0 @@ |
|||||||
class LibreTranslateService { |
|
||||||
static instance: LibreTranslateService |
|
||||||
|
|
||||||
constructor() { |
|
||||||
if (!LibreTranslateService.instance) { |
|
||||||
LibreTranslateService.instance = this |
|
||||||
} |
|
||||||
return LibreTranslateService.instance |
|
||||||
} |
|
||||||
|
|
||||||
async translate( |
|
||||||
text: string, |
|
||||||
target: string, |
|
||||||
server?: string, |
|
||||||
api_key?: string |
|
||||||
): Promise<string> { |
|
||||||
if (!text) { |
|
||||||
return text |
|
||||||
} |
|
||||||
if (!server) { |
|
||||||
throw new Error('LibreTranslate server address is not configured') |
|
||||||
} |
|
||||||
const url = new URL('/translate', server).toString() |
|
||||||
const response = await fetch(url, { |
|
||||||
method: 'POST', |
|
||||||
headers: { 'Content-Type': 'application/json' }, |
|
||||||
body: JSON.stringify({ q: text, target, source: 'auto', api_key }) |
|
||||||
}) |
|
||||||
const data = await response.json() |
|
||||||
if (!response.ok) { |
|
||||||
throw new Error(data.error ?? 'Failed to translate') |
|
||||||
} |
|
||||||
const translatedText = data.translatedText |
|
||||||
if (!translatedText) { |
|
||||||
throw new Error('Translation failed') |
|
||||||
} |
|
||||||
return translatedText |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
const instance = new LibreTranslateService() |
|
||||||
export default instance |
|
||||||
@ -1,136 +0,0 @@ |
|||||||
import { JUMBLE_API_BASE_URL } from '@/constants' |
|
||||||
import client from '@/services/client.service' |
|
||||||
import { TTranslationAccount } from '@/types' |
|
||||||
|
|
||||||
class TranslationService { |
|
||||||
static instance: TranslationService |
|
||||||
|
|
||||||
private apiKeyMap: Record<string, string | undefined> = {} |
|
||||||
private currentPubkey: string | null = null |
|
||||||
|
|
||||||
constructor() { |
|
||||||
if (!TranslationService.instance) { |
|
||||||
TranslationService.instance = this |
|
||||||
} |
|
||||||
return TranslationService.instance |
|
||||||
} |
|
||||||
|
|
||||||
async getAccount(): Promise<TTranslationAccount> { |
|
||||||
if (!this.currentPubkey) { |
|
||||||
throw new Error('Please login first') |
|
||||||
} |
|
||||||
const apiKey = this.apiKeyMap[this.currentPubkey] |
|
||||||
const path = '/v1/translation/account' |
|
||||||
const method = 'GET' |
|
||||||
let auth: string | undefined |
|
||||||
if (!apiKey) { |
|
||||||
auth = await client.signHttpAuth( |
|
||||||
new URL(path, JUMBLE_API_BASE_URL).toString(), |
|
||||||
method, |
|
||||||
'Auth to get Jumble translation service account' |
|
||||||
) |
|
||||||
} |
|
||||||
const act = await this._fetch<TTranslationAccount>({ |
|
||||||
path, |
|
||||||
method, |
|
||||||
auth, |
|
||||||
retryWhenUnauthorized: !auth |
|
||||||
}) |
|
||||||
|
|
||||||
if (act.api_key && act.pubkey) { |
|
||||||
this.apiKeyMap[act.pubkey] = act.api_key |
|
||||||
} |
|
||||||
|
|
||||||
return act |
|
||||||
} |
|
||||||
|
|
||||||
async regenerateApiKey(): Promise<string> { |
|
||||||
try { |
|
||||||
const data = await this._fetch({ |
|
||||||
path: '/v1/translation/regenerate-api-key', |
|
||||||
method: 'POST' |
|
||||||
}) |
|
||||||
if (data.api_key && this.currentPubkey) { |
|
||||||
this.apiKeyMap[this.currentPubkey] = data.api_key |
|
||||||
} |
|
||||||
return data.api_key |
|
||||||
} catch (error) { |
|
||||||
const errMsg = error instanceof Error ? error.message : '' |
|
||||||
throw new Error(errMsg || 'Failed to regenerate API key') |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
async translate(text: string, target: string): Promise<string> { |
|
||||||
if (!text) { |
|
||||||
return text |
|
||||||
} |
|
||||||
try { |
|
||||||
const data = await this._fetch({ |
|
||||||
path: '/v1/translation/translate', |
|
||||||
method: 'POST', |
|
||||||
body: JSON.stringify({ q: text, target }) |
|
||||||
}) |
|
||||||
const translatedText = data.translatedText |
|
||||||
if (!translatedText) { |
|
||||||
throw new Error('Translation failed') |
|
||||||
} |
|
||||||
return translatedText |
|
||||||
} catch (error) { |
|
||||||
const errMsg = error instanceof Error ? error.message : '' |
|
||||||
throw new Error(errMsg || 'Failed to translate') |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
changeCurrentPubkey(pubkey: string | null): void { |
|
||||||
this.currentPubkey = pubkey |
|
||||||
} |
|
||||||
|
|
||||||
private async _fetch<T = any>({ |
|
||||||
path, |
|
||||||
method, |
|
||||||
body, |
|
||||||
auth, |
|
||||||
retryWhenUnauthorized = true |
|
||||||
}: { |
|
||||||
path: string |
|
||||||
method: string |
|
||||||
body?: string |
|
||||||
auth?: string |
|
||||||
retryWhenUnauthorized?: boolean |
|
||||||
}): Promise<T> { |
|
||||||
if (!this.currentPubkey) { |
|
||||||
throw new Error('Please login first') |
|
||||||
} |
|
||||||
const apiKey = this.apiKeyMap[this.currentPubkey] |
|
||||||
const hasApiKey = !!apiKey |
|
||||||
let _auth: string |
|
||||||
if (auth) { |
|
||||||
_auth = auth |
|
||||||
} else if (hasApiKey) { |
|
||||||
_auth = `Bearer ${apiKey}` |
|
||||||
} else { |
|
||||||
const act = await this.getAccount() |
|
||||||
_auth = `Bearer ${act.api_key}` |
|
||||||
} |
|
||||||
|
|
||||||
const url = new URL(path, JUMBLE_API_BASE_URL).toString() |
|
||||||
const response = await fetch(url, { |
|
||||||
method, |
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: _auth }, |
|
||||||
body |
|
||||||
}) |
|
||||||
|
|
||||||
const data = await response.json() |
|
||||||
if (!response.ok) { |
|
||||||
if (data.code === '00403' && hasApiKey && retryWhenUnauthorized) { |
|
||||||
this.apiKeyMap[this.currentPubkey] = undefined |
|
||||||
return this._fetch({ path, method, body, retryWhenUnauthorized: false }) |
|
||||||
} |
|
||||||
throw new Error(data.error) |
|
||||||
} |
|
||||||
return data |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
const instance = new TranslationService() |
|
||||||
export default instance |
|
||||||
Loading…
Reference in new issue