chore: i18n
This commit is contained in:
86
package-lock.json
generated
86
package-lock.json
generated
@@ -32,11 +32,14 @@
|
|||||||
"dataloader": "^2.2.2",
|
"dataloader": "^2.2.2",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"framer-motion": "^11.11.17",
|
"framer-motion": "^11.11.17",
|
||||||
|
"i18next": "^23.16.5",
|
||||||
|
"i18next-browser-languagedetector": "^8.0.0",
|
||||||
"lru-cache": "^11.0.1",
|
"lru-cache": "^11.0.1",
|
||||||
"lucide-react": "^0.453.0",
|
"lucide-react": "^0.453.0",
|
||||||
"nostr-tools": "^2.9.1",
|
"nostr-tools": "^2.9.1",
|
||||||
"path-to-regexp": "^8.2.0",
|
"path-to-regexp": "^8.2.0",
|
||||||
"qrcode.react": "^4.1.0",
|
"qrcode.react": "^4.1.0",
|
||||||
|
"react-i18next": "^15.1.1",
|
||||||
"react-resizable-panels": "^2.1.5",
|
"react-resizable-panels": "^2.1.5",
|
||||||
"react-string-replace": "^1.1.1",
|
"react-string-replace": "^1.1.1",
|
||||||
"tailwind-merge": "^2.5.4",
|
"tailwind-merge": "^2.5.4",
|
||||||
@@ -340,6 +343,17 @@
|
|||||||
"@babel/core": "^7.0.0-0"
|
"@babel/core": "^7.0.0-0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@babel/runtime": {
|
||||||
|
"version": "7.26.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz",
|
||||||
|
"integrity": "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==",
|
||||||
|
"dependencies": {
|
||||||
|
"regenerator-runtime": "^0.14.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/template": {
|
"node_modules/@babel/template": {
|
||||||
"version": "7.25.9",
|
"version": "7.25.9",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz",
|
||||||
@@ -6732,6 +6746,14 @@
|
|||||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/html-parse-stringify": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
|
||||||
|
"dependencies": {
|
||||||
|
"void-elements": "3.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/http-cache-semantics": {
|
"node_modules/http-cache-semantics": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz",
|
||||||
@@ -6776,6 +6798,36 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/i18next": {
|
||||||
|
"version": "23.16.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/i18next/-/i18next-23.16.5.tgz",
|
||||||
|
"integrity": "sha512-KTlhE3EP9x6pPTAW7dy0WKIhoCpfOGhRQlO+jttQLgzVaoOjWwBWramu7Pp0i+8wDNduuzXfe3kkVbzrKyrbTA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://locize.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://locize.com/i18next.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.23.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/i18next-browser-languagedetector": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-zhXdJXTTCoG39QsrOCiOabnWj2jecouOqbchu3EfhtSHxIB5Uugnm9JaizenOy39h7ne3+fLikIjeW88+rgszw==",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.23.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/iconv-corefoundation": {
|
"node_modules/iconv-corefoundation": {
|
||||||
"version": "1.1.7",
|
"version": "1.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz",
|
||||||
@@ -8587,6 +8639,27 @@
|
|||||||
"react": "^18.3.1"
|
"react": "^18.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-i18next": {
|
||||||
|
"version": "15.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.1.1.tgz",
|
||||||
|
"integrity": "sha512-R/Vg9wIli2P3FfeI8o1eNJUJue5LWpFsQePCHdQDmX0Co3zkr6kdT8gAseb/yGeWbNz1Txc4bKDQuZYsC0kQfw==",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.25.0",
|
||||||
|
"html-parse-stringify": "^3.0.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"i18next": ">= 23.2.3",
|
||||||
|
"react": ">= 16.8.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-native": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-is": {
|
"node_modules/react-is": {
|
||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
@@ -8781,6 +8854,11 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/regenerator-runtime": {
|
||||||
|
"version": "0.14.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
|
||||||
|
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="
|
||||||
|
},
|
||||||
"node_modules/regexp.prototype.flags": {
|
"node_modules/regexp.prototype.flags": {
|
||||||
"version": "1.5.3",
|
"version": "1.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.3.tgz",
|
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.3.tgz",
|
||||||
@@ -10082,6 +10160,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/void-elements": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|||||||
@@ -50,11 +50,14 @@
|
|||||||
"dataloader": "^2.2.2",
|
"dataloader": "^2.2.2",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"framer-motion": "^11.11.17",
|
"framer-motion": "^11.11.17",
|
||||||
|
"i18next": "^23.16.5",
|
||||||
|
"i18next-browser-languagedetector": "^8.0.0",
|
||||||
"lru-cache": "^11.0.1",
|
"lru-cache": "^11.0.1",
|
||||||
"lucide-react": "^0.453.0",
|
"lucide-react": "^0.453.0",
|
||||||
"nostr-tools": "^2.9.1",
|
"nostr-tools": "^2.9.1",
|
||||||
"path-to-regexp": "^8.2.0",
|
"path-to-regexp": "^8.2.0",
|
||||||
"qrcode.react": "^4.1.0",
|
"qrcode.react": "^4.1.0",
|
||||||
|
"react-i18next": "^15.1.1",
|
||||||
"react-resizable-panels": "^2.1.5",
|
"react-resizable-panels": "^2.1.5",
|
||||||
"react-string-replace": "^1.1.1",
|
"react-string-replace": "^1.1.1",
|
||||||
"tailwind-merge": "^2.5.4",
|
"tailwind-merge": "^2.5.4",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { toProfile } from '@renderer/lib/link'
|
|||||||
import { generateImageByPubkey } from '@renderer/lib/pubkey'
|
import { generateImageByPubkey } from '@renderer/lib/pubkey'
|
||||||
import { useSecondaryPage } from '@renderer/PageManager'
|
import { useSecondaryPage } from '@renderer/PageManager'
|
||||||
import { useNostr } from '@renderer/providers/NostrProvider'
|
import { useNostr } from '@renderer/providers/NostrProvider'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
export default function ProfileButton({
|
export default function ProfileButton({
|
||||||
pubkey,
|
pubkey,
|
||||||
@@ -19,6 +20,7 @@ export default function ProfileButton({
|
|||||||
pubkey: string
|
pubkey: string
|
||||||
variant?: 'titlebar' | 'sidebar'
|
variant?: 'titlebar' | 'sidebar'
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const { logout } = useNostr()
|
const { logout } = useNostr()
|
||||||
const { profile } = useFetchProfile(pubkey)
|
const { profile } = useFetchProfile(pubkey)
|
||||||
const { push } = useSecondaryPage()
|
const { push } = useSecondaryPage()
|
||||||
@@ -61,9 +63,9 @@ export default function ProfileButton({
|
|||||||
{triggerComponent}
|
{triggerComponent}
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent>
|
<DropdownMenuContent>
|
||||||
<DropdownMenuItem onClick={() => push(toProfile(pubkey))}>Profile</DropdownMenuItem>
|
<DropdownMenuItem onClick={() => push(toProfile(pubkey))}>{t('Profile')}</DropdownMenuItem>
|
||||||
<DropdownMenuItem className="text-destructive focus:text-destructive" onClick={logout}>
|
<DropdownMenuItem className="text-destructive focus:text-destructive" onClick={logout}>
|
||||||
Logout
|
{t('Logout')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import { useFollowList } from '@renderer/providers/FollowListProvider'
|
|||||||
import { useNostr } from '@renderer/providers/NostrProvider'
|
import { useNostr } from '@renderer/providers/NostrProvider'
|
||||||
import { Loader } from 'lucide-react'
|
import { Loader } from 'lucide-react'
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
export default function FollowButton({ pubkey }: { pubkey: string }) {
|
export default function FollowButton({ pubkey }: { pubkey: string }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const { pubkey: accountPubkey, checkLogin } = useNostr()
|
const { pubkey: accountPubkey, checkLogin } = useNostr()
|
||||||
const { followListEvent, followings, isReady, follow, unfollow } = useFollowList()
|
const { followListEvent, followings, isReady, follow, unfollow } = useFollowList()
|
||||||
@@ -24,7 +26,7 @@ export default function FollowButton({ pubkey }: { pubkey: string }) {
|
|||||||
await follow(pubkey)
|
await follow(pubkey)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Follow failed',
|
title: t('Follow failed'),
|
||||||
description: (error as Error).message,
|
description: (error as Error).message,
|
||||||
variant: 'destructive'
|
variant: 'destructive'
|
||||||
})
|
})
|
||||||
@@ -44,7 +46,7 @@ export default function FollowButton({ pubkey }: { pubkey: string }) {
|
|||||||
await unfollow(pubkey)
|
await unfollow(pubkey)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Unfollow failed',
|
title: t('Unfollow failed'),
|
||||||
description: (error as Error).message,
|
description: (error as Error).message,
|
||||||
variant: 'destructive'
|
variant: 'destructive'
|
||||||
})
|
})
|
||||||
@@ -61,11 +63,11 @@ export default function FollowButton({ pubkey }: { pubkey: string }) {
|
|||||||
onClick={handleUnfollow}
|
onClick={handleUnfollow}
|
||||||
disabled={updating}
|
disabled={updating}
|
||||||
>
|
>
|
||||||
{updating ? <Loader className="animate-spin" /> : 'Unfollow'}
|
{updating ? <Loader className="animate-spin" /> : t('Unfollow')}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button className="w-20 min-w-20 rounded-full" onClick={handleFollow} disabled={updating}>
|
<Button className="w-20 min-w-20 rounded-full" onClick={handleFollow} disabled={updating}>
|
||||||
{updating ? <Loader className="animate-spin" /> : 'Follow'}
|
{updating ? <Loader className="animate-spin" /> : t('Follow')}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import { Input } from '@renderer/components/ui/input'
|
import { Input } from '@renderer/components/ui/input'
|
||||||
import { useNostr } from '@renderer/providers/NostrProvider'
|
import { useNostr } from '@renderer/providers/NostrProvider'
|
||||||
import { Dispatch, useState } from 'react'
|
import { Dispatch, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
export default function LoginDialog({
|
export default function LoginDialog({
|
||||||
open,
|
open,
|
||||||
@@ -17,6 +18,7 @@ export default function LoginDialog({
|
|||||||
open: boolean
|
open: boolean
|
||||||
setOpen: Dispatch<boolean>
|
setOpen: Dispatch<boolean>
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const { login, canLogin } = useNostr()
|
const { login, canLogin } = useNostr()
|
||||||
const [nsec, setNsec] = useState('')
|
const [nsec, setNsec] = useState('')
|
||||||
const [errMsg, setErrMsg] = useState<string | null>(null)
|
const [errMsg, setErrMsg] = useState<string | null>(null)
|
||||||
@@ -40,7 +42,7 @@ export default function LoginDialog({
|
|||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogContent className="w-80">
|
<DialogContent className="w-80">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Sign in</DialogTitle>
|
<DialogTitle />
|
||||||
<DialogDescription className="text-destructive">
|
<DialogDescription className="text-destructive">
|
||||||
{!canLogin && 'Encryption is not available in your device.'}
|
{!canLogin && 'Encryption is not available in your device.'}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
@@ -57,7 +59,7 @@ export default function LoginDialog({
|
|||||||
{errMsg && <div className="text-xs text-destructive pl-3">{errMsg}</div>}
|
{errMsg && <div className="text-xs text-destructive pl-3">{errMsg}</div>}
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={handleLogin} disabled={!canLogin}>
|
<Button onClick={handleLogin} disabled={!canLogin}>
|
||||||
Login
|
{t('Login')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -3,18 +3,25 @@ import { Repeat2 } from 'lucide-react'
|
|||||||
import { Event, kinds, verifyEvent } from 'nostr-tools'
|
import { Event, kinds, verifyEvent } from 'nostr-tools'
|
||||||
import Username from '../Username'
|
import Username from '../Username'
|
||||||
import ShortTextNoteCard from './ShortTextNoteCard'
|
import ShortTextNoteCard from './ShortTextNoteCard'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
export default function RepostNoteCard({ event, className }: { event: Event; className?: string }) {
|
export default function RepostNoteCard({ event, className }: { event: Event; className?: string }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const targetEvent = useMemo(() => {
|
||||||
const targetEvent = event.content ? (JSON.parse(event.content) as Event) : null
|
const targetEvent = event.content ? (JSON.parse(event.content) as Event) : null
|
||||||
try {
|
try {
|
||||||
if (!targetEvent || !verifyEvent(targetEvent) || targetEvent.kind !== kinds.ShortTextNote) {
|
if (!targetEvent || !verifyEvent(targetEvent) || targetEvent.kind !== kinds.ShortTextNote) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
client.addEventToCache(targetEvent)
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
client.addEventToCache(targetEvent)
|
return targetEvent
|
||||||
|
}, [event])
|
||||||
|
if (!targetEvent) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
@@ -25,7 +32,7 @@ export default function RepostNoteCard({ event, className }: { event: Event; cla
|
|||||||
className="font-semibold truncate"
|
className="font-semibold truncate"
|
||||||
skeletonClassName="h-3"
|
skeletonClassName="h-3"
|
||||||
/>
|
/>
|
||||||
<div>reposted</div>
|
<div>{t('reposted')}</div>
|
||||||
</div>
|
</div>
|
||||||
<ShortTextNoteCard event={targetEvent} />
|
<ShortTextNoteCard event={targetEvent} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import dayjs from 'dayjs'
|
|||||||
import { Event, Filter, kinds } from 'nostr-tools'
|
import { Event, Filter, kinds } from 'nostr-tools'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import NoteCard from '../NoteCard'
|
import NoteCard from '../NoteCard'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
export default function NoteList({
|
export default function NoteList({
|
||||||
relayUrls,
|
relayUrls,
|
||||||
@@ -17,6 +18,7 @@ export default function NoteList({
|
|||||||
filter?: Filter
|
filter?: Filter
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const { isReady, singEvent } = useNostr()
|
const { isReady, singEvent } = useNostr()
|
||||||
const [events, setEvents] = useState<Event[]>([])
|
const [events, setEvents] = useState<Event[]>([])
|
||||||
const [newEvents, setNewEvents] = useState<Event[]>([])
|
const [newEvents, setNewEvents] = useState<Event[]>([])
|
||||||
@@ -127,7 +129,7 @@ export default function NoteList({
|
|||||||
{newEvents.length > 0 && (
|
{newEvents.length > 0 && (
|
||||||
<div className="flex justify-center w-full mb-4">
|
<div className="flex justify-center w-full mb-4">
|
||||||
<Button size="lg" onClick={showNewEvents}>
|
<Button size="lg" onClick={showNewEvents}>
|
||||||
show new notes
|
{t('show new notes')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -137,7 +139,7 @@ export default function NoteList({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center text-sm text-muted-foreground mt-2">
|
<div className="text-center text-sm text-muted-foreground mt-2">
|
||||||
{hasMore ? <div ref={bottomRef}>loading...</div> : 'no more notes'}
|
{hasMore ? <div ref={bottomRef}>{t('loading...')}</div> : t('no more notes')}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Heart, Loader } from 'lucide-react'
|
|||||||
import { Event } from 'nostr-tools'
|
import { Event } from 'nostr-tools'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { formatCount } from './utils'
|
import { formatCount } from './utils'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
export default function LikeButton({
|
export default function LikeButton({
|
||||||
event,
|
event,
|
||||||
@@ -17,6 +18,7 @@ export default function LikeButton({
|
|||||||
variant?: 'normal' | 'reply'
|
variant?: 'normal' | 'reply'
|
||||||
canFetch?: boolean
|
canFetch?: boolean
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const { publish, checkLogin } = useNostr()
|
const { publish, checkLogin } = useNostr()
|
||||||
const { noteStatsMap, fetchNoteLikedStatus, fetchNoteLikeCount, markNoteAsLiked } = useNoteStats()
|
const { noteStatsMap, fetchNoteLikedStatus, fetchNoteLikeCount, markNoteAsLiked } = useNoteStats()
|
||||||
const [liking, setLiking] = useState(false)
|
const [liking, setLiking] = useState(false)
|
||||||
@@ -74,7 +76,7 @@ export default function LikeButton({
|
|||||||
)}
|
)}
|
||||||
onClick={like}
|
onClick={like}
|
||||||
disabled={!canLike}
|
disabled={!canLike}
|
||||||
title="like"
|
title={t('Like')}
|
||||||
>
|
>
|
||||||
{liking ? (
|
{liking ? (
|
||||||
<Loader className="animate-spin" size={16} />
|
<Loader className="animate-spin" size={16} />
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ import { Code, Copy, Ellipsis } from 'lucide-react'
|
|||||||
import { Event } from 'nostr-tools'
|
import { Event } from 'nostr-tools'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import RawEventDialog from './RawEventDialog'
|
import RawEventDialog from './RawEventDialog'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
export default function NoteOptions({ event }: { event: Event }) {
|
export default function NoteOptions({ event }: { event: Event }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [isRawEventDialogOpen, setIsRawEventDialogOpen] = useState(false)
|
const [isRawEventDialogOpen, setIsRawEventDialogOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -30,7 +32,7 @@ export default function NoteOptions({ event }: { event: Event }) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Copy />
|
<Copy />
|
||||||
copy embedded code
|
{t('copy embedded code')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -39,7 +41,7 @@ export default function NoteOptions({ event }: { event: Event }) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Code />
|
<Code />
|
||||||
raw event
|
{t('raw event')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import { Event } from 'nostr-tools'
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import PostDialog from '../PostDialog'
|
import PostDialog from '../PostDialog'
|
||||||
import { formatCount } from './utils'
|
import { formatCount } from './utils'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
export default function ReplyButton({ event }: { event: Event }) {
|
export default function ReplyButton({ event }: { event: Event }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const { noteStatsMap } = useNoteStats()
|
const { noteStatsMap } = useNoteStats()
|
||||||
const { pubkey } = useNostr()
|
const { pubkey } = useNostr()
|
||||||
const { replyCount } = useMemo(() => noteStatsMap.get(event.id) ?? {}, [noteStatsMap, event.id])
|
const { replyCount } = useMemo(() => noteStatsMap.get(event.id) ?? {}, [noteStatsMap, event.id])
|
||||||
@@ -21,6 +23,7 @@ export default function ReplyButton({ event }: { event: Event }) {
|
|||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
setOpen(true)
|
setOpen(true)
|
||||||
}}
|
}}
|
||||||
|
title={t('Reply')}
|
||||||
>
|
>
|
||||||
<MessageCircle size={16} />
|
<MessageCircle size={16} />
|
||||||
<div className="text-sm">{formatCount(replyCount)}</div>
|
<div className="text-sm">{formatCount(replyCount)}</div>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { Event } from 'nostr-tools'
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import PostDialog from '../PostDialog'
|
import PostDialog from '../PostDialog'
|
||||||
import { formatCount } from './utils'
|
import { formatCount } from './utils'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
export default function RepostButton({
|
export default function RepostButton({
|
||||||
event,
|
event,
|
||||||
@@ -23,6 +24,7 @@ export default function RepostButton({
|
|||||||
event: Event
|
event: Event
|
||||||
canFetch?: boolean
|
canFetch?: boolean
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const { publish, checkLogin } = useNostr()
|
const { publish, checkLogin } = useNostr()
|
||||||
const { noteStatsMap, fetchNoteRepostCount, fetchNoteRepostedStatus, markNoteAsReposted } =
|
const { noteStatsMap, fetchNoteRepostCount, fetchNoteRepostedStatus, markNoteAsReposted } =
|
||||||
useNoteStats()
|
useNoteStats()
|
||||||
@@ -84,7 +86,7 @@ export default function RepostButton({
|
|||||||
)}
|
)}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
disabled={!canRepost}
|
disabled={!canRepost}
|
||||||
title="repost"
|
title={t('Repost')}
|
||||||
>
|
>
|
||||||
{reposting ? <Loader className="animate-spin" size={16} /> : <Repeat size={16} />}
|
{reposting ? <Loader className="animate-spin" size={16} /> : <Repeat size={16} />}
|
||||||
<div className="text-sm">{formatCount(repostCount)}</div>
|
<div className="text-sm">{formatCount(repostCount)}</div>
|
||||||
@@ -97,7 +99,7 @@ export default function RepostButton({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenuItem onClick={repost}>
|
<DropdownMenuItem onClick={repost}>
|
||||||
<Repeat /> Repost
|
<Repeat /> {t('Repost')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -105,7 +107,7 @@ export default function RepostButton({
|
|||||||
setIsPostDialogOpen(true)
|
setIsPostDialogOpen(true)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PencilLine /> Quote
|
<PencilLine /> {t('Quote')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Event } from 'nostr-tools'
|
import { Event } from 'nostr-tools'
|
||||||
import UserAvatar from '../UserAvatar'
|
import UserAvatar from '../UserAvatar'
|
||||||
import { cn } from '@renderer/lib/utils'
|
import { cn } from '@renderer/lib/utils'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
export default function ParentNotePreview({
|
export default function ParentNotePreview({
|
||||||
event,
|
event,
|
||||||
@@ -11,6 +12,7 @@ export default function ParentNotePreview({
|
|||||||
className?: string
|
className?: string
|
||||||
onClick?: React.MouseEventHandler<HTMLDivElement> | undefined
|
onClick?: React.MouseEventHandler<HTMLDivElement> | undefined
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -19,7 +21,7 @@ export default function ParentNotePreview({
|
|||||||
)}
|
)}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
<div className="shrink-0">reply to</div>
|
<div className="shrink-0">{t('reply to')}</div>
|
||||||
<UserAvatar userId={event.pubkey} size="tiny" />
|
<UserAvatar userId={event.pubkey} size="tiny" />
|
||||||
<div className="truncate">{event.content}</div>
|
<div className="truncate">{event.content}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import PostDialog from '@renderer/components/PostDialog'
|
|||||||
import { Button } from '@renderer/components/ui/button'
|
import { Button } from '@renderer/components/ui/button'
|
||||||
import { PencilLine } from 'lucide-react'
|
import { PencilLine } from 'lucide-react'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
export default function PostButton({ variant = 'titlebar' }: { variant?: 'titlebar' | 'sidebar' }) {
|
export default function PostButton({ variant = 'titlebar' }: { variant?: 'titlebar' | 'sidebar' }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -11,14 +13,14 @@ export default function PostButton({ variant = 'titlebar' }: { variant?: 'titleb
|
|||||||
<Button
|
<Button
|
||||||
variant={variant}
|
variant={variant}
|
||||||
size={variant}
|
size={variant}
|
||||||
title="new post"
|
title={t('New post')}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
setOpen(true)
|
setOpen(true)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PencilLine />
|
<PencilLine />
|
||||||
{variant === 'sidebar' && <div>Post</div>}
|
{variant === 'sidebar' && <div>{t('Post')}</div>}
|
||||||
</Button>
|
</Button>
|
||||||
<PostDialog open={open} setOpen={setOpen} />
|
<PostDialog open={open} setOpen={setOpen} />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Event } from 'nostr-tools'
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import UserAvatar from '../UserAvatar'
|
import UserAvatar from '../UserAvatar'
|
||||||
import Username from '../Username'
|
import Username from '../Username'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
export default function Mentions({
|
export default function Mentions({
|
||||||
content,
|
content,
|
||||||
@@ -14,6 +15,7 @@ export default function Mentions({
|
|||||||
content: string
|
content: string
|
||||||
parentEvent?: Event
|
parentEvent?: Event
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const { pubkey } = useNostr()
|
const { pubkey } = useNostr()
|
||||||
const [pubkeys, setPubkeys] = useState<string[]>([])
|
const [pubkeys, setPubkeys] = useState<string[]>([])
|
||||||
|
|
||||||
@@ -32,7 +34,7 @@ export default function Mentions({
|
|||||||
disabled={pubkeys.length === 0}
|
disabled={pubkeys.length === 0}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
Mentions {pubkeys.length > 0 && `(${pubkeys.length})`}
|
{t('Mentions')} {pubkeys.length > 0 && `(${pubkeys.length})`}
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-48">
|
<PopoverContent className="w-48">
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import UserAvatar from '../UserAvatar'
|
|||||||
import Mentions from './Metions'
|
import Mentions from './Metions'
|
||||||
import Preview from './Preview'
|
import Preview from './Preview'
|
||||||
import Uploader from './Uploader'
|
import Uploader from './Uploader'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
export default function PostDialog({
|
export default function PostDialog({
|
||||||
defaultContent = '',
|
defaultContent = '',
|
||||||
@@ -31,6 +32,7 @@ export default function PostDialog({
|
|||||||
open: boolean
|
open: boolean
|
||||||
setOpen: Dispatch<boolean>
|
setOpen: Dispatch<boolean>
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const { publish, checkLogin } = useNostr()
|
const { publish, checkLogin } = useNostr()
|
||||||
const [content, setContent] = useState(defaultContent)
|
const [content, setContent] = useState(defaultContent)
|
||||||
@@ -65,14 +67,14 @@ export default function PostDialog({
|
|||||||
error.errors.forEach((e) =>
|
error.errors.forEach((e) =>
|
||||||
toast({
|
toast({
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
title: 'Failed to post',
|
title: t('Failed to post'),
|
||||||
description: e.message
|
description: e.message
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
} else if (error instanceof Error) {
|
} else if (error instanceof Error) {
|
||||||
toast({
|
toast({
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
title: 'Failed to post',
|
title: t('Failed to post'),
|
||||||
description: error.message
|
description: error.message
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -82,8 +84,8 @@ export default function PostDialog({
|
|||||||
setPosting(false)
|
setPosting(false)
|
||||||
}
|
}
|
||||||
toast({
|
toast({
|
||||||
title: 'Post successful',
|
title: t('Post successful'),
|
||||||
description: 'Your post has been published'
|
description: t('Your post has been published')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -102,7 +104,7 @@ export default function PostDialog({
|
|||||||
<div className="truncate">{parentEvent.content}</div>
|
<div className="truncate">{parentEvent.content}</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
'New post'
|
t('New post')
|
||||||
)}
|
)}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription />
|
<DialogDescription />
|
||||||
@@ -111,7 +113,7 @@ export default function PostDialog({
|
|||||||
className="h-32"
|
className="h-32"
|
||||||
onChange={handleTextareaChange}
|
onChange={handleTextareaChange}
|
||||||
value={content}
|
value={content}
|
||||||
placeholder="Write something..."
|
placeholder={t('Write something...')}
|
||||||
/>
|
/>
|
||||||
{content && <Preview content={content} />}
|
{content && <Preview content={content} />}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -125,11 +127,11 @@ export default function PostDialog({
|
|||||||
setOpen(false)
|
setOpen(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Cancel
|
{t('Cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={!canPost} onClick={post}>
|
<Button type="submit" disabled={!canPost} onClick={post}>
|
||||||
{posting && <LoaderCircle className="animate-spin" />}
|
{posting && <LoaderCircle className="animate-spin" />}
|
||||||
Post
|
{t('Post')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
import { Button } from '@renderer/components/ui/button'
|
import { Button } from '@renderer/components/ui/button'
|
||||||
import { usePrimaryPage } from '@renderer/PageManager'
|
import { usePrimaryPage } from '@renderer/PageManager'
|
||||||
import { RefreshCcw } from 'lucide-react'
|
import { RefreshCcw } from 'lucide-react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
export default function RefreshButton({
|
export default function RefreshButton({
|
||||||
variant = 'titlebar'
|
variant = 'titlebar'
|
||||||
}: {
|
}: {
|
||||||
variant?: 'titlebar' | 'sidebar'
|
variant?: 'titlebar' | 'sidebar'
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const { refresh } = usePrimaryPage()
|
const { refresh } = usePrimaryPage()
|
||||||
return (
|
return (
|
||||||
<Button variant={variant} size={variant} onClick={refresh} title="reload">
|
<Button variant={variant} size={variant} onClick={refresh} title={t('Refresh')}>
|
||||||
<RefreshCcw />
|
<RefreshCcw />
|
||||||
{variant === 'sidebar' && <div>Refresh</div>}
|
{variant === 'sidebar' && <div>{t('Refresh')}</div>}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,18 +3,20 @@ import { Button } from '@renderer/components/ui/button'
|
|||||||
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'
|
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'
|
||||||
import { ScrollArea } from '@renderer/components/ui/scroll-area'
|
import { ScrollArea } from '@renderer/components/ui/scroll-area'
|
||||||
import { Server } from 'lucide-react'
|
import { Server } from 'lucide-react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
export default function RelaySettingsPopover({
|
export default function RelaySettingsPopover({
|
||||||
variant = 'titlebar'
|
variant = 'titlebar'
|
||||||
}: {
|
}: {
|
||||||
variant?: 'titlebar' | 'sidebar'
|
variant?: 'titlebar' | 'sidebar'
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
return (
|
return (
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button variant={variant} size={variant} title="relay settings">
|
<Button variant={variant} size={variant} title={t('Relay settings')}>
|
||||||
<Server />
|
<Server />
|
||||||
{variant === 'sidebar' && <div>Relays</div>}
|
{variant === 'sidebar' && <div>{t('SidebarRelays')}</div>}
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import ParentNotePreview from '../ParentNotePreview'
|
|||||||
import PostDialog from '../PostDialog'
|
import PostDialog from '../PostDialog'
|
||||||
import UserAvatar from '../UserAvatar'
|
import UserAvatar from '../UserAvatar'
|
||||||
import Username from '../Username'
|
import Username from '../Username'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
export default function ReplyNote({
|
export default function ReplyNote({
|
||||||
event,
|
event,
|
||||||
@@ -19,6 +20,7 @@ export default function ReplyNote({
|
|||||||
onClickParent?: (eventId: string) => void
|
onClickParent?: (eventId: string) => void
|
||||||
highlight?: boolean
|
highlight?: boolean
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [isPostDialogOpen, setIsPostDialogOpen] = useState(false)
|
const [isPostDialogOpen, setIsPostDialogOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -42,7 +44,7 @@ export default function ReplyNote({
|
|||||||
className="text-muted-foreground hover:text-primary cursor-pointer"
|
className="text-muted-foreground hover:text-primary cursor-pointer"
|
||||||
onClick={() => setIsPostDialogOpen(true)}
|
onClick={() => setIsPostDialogOpen(true)}
|
||||||
>
|
>
|
||||||
reply
|
{t('reply')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import dayjs from 'dayjs'
|
|||||||
import { Event } from 'nostr-tools'
|
import { Event } from 'nostr-tools'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import ReplyNote from '../ReplyNote'
|
import ReplyNote from '../ReplyNote'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
export default function ReplyNoteList({ event, className }: { event: Event; className?: string }) {
|
export default function ReplyNoteList({ event, className }: { event: Event; className?: string }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [replies, setReplies] = useState<Event[]>([])
|
const [replies, setReplies] = useState<Event[]>([])
|
||||||
const [replyMap, setReplyMap] = useState<
|
const [replyMap, setReplyMap] = useState<
|
||||||
Record<string, { event: Event; level: number; parent?: Event } | undefined>
|
Record<string, { event: Event; level: number; parent?: Event } | undefined>
|
||||||
@@ -98,7 +100,7 @@ export default function ReplyNoteList({ event, className }: { event: Event; clas
|
|||||||
className={`text-sm text-center text-muted-foreground ${!loading ? 'hover:text-foreground cursor-pointer' : ''}`}
|
className={`text-sm text-center text-muted-foreground ${!loading ? 'hover:text-foreground cursor-pointer' : ''}`}
|
||||||
onClick={loadMore}
|
onClick={loadMore}
|
||||||
>
|
>
|
||||||
{loading ? 'loading...' : hasMore ? 'load more older replies' : null}
|
{loading ? t('loading...') : hasMore ? t('load more older replies') : null}
|
||||||
</div>
|
</div>
|
||||||
{replies.length > 0 && (loading || hasMore) && <Separator className="my-4" />}
|
{replies.length > 0 && (loading || hasMore) && <Separator className="my-4" />}
|
||||||
<div className={cn('mb-4', className)}>
|
<div className={cn('mb-4', className)}>
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import AccountButton from '../AccountButton'
|
|||||||
import PostButton from '../PostButton'
|
import PostButton from '../PostButton'
|
||||||
import RefreshButton from '../RefreshButton'
|
import RefreshButton from '../RefreshButton'
|
||||||
import RelaySettingsPopover from '../RelaySettingsPopover'
|
import RelaySettingsPopover from '../RelaySettingsPopover'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
export default function PrimaryPageSidebar() {
|
export default function PrimaryPageSidebar() {
|
||||||
|
const { t } = useTranslation()
|
||||||
return (
|
return (
|
||||||
<div className="draggable w-52 h-full shrink-0 hidden xl:flex flex-col pb-8 pt-9 pl-4 justify-between">
|
<div className="draggable w-52 h-full shrink-0 hidden xl:flex flex-col pb-8 pt-9 pl-4 justify-between">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -23,7 +25,7 @@ export default function PrimaryPageSidebar() {
|
|||||||
<AboutInfoDialog>
|
<AboutInfoDialog>
|
||||||
<Button variant="sidebar" size="sidebar">
|
<Button variant="sidebar" size="sidebar">
|
||||||
<Info />
|
<Info />
|
||||||
About
|
{t('About')}
|
||||||
</Button>
|
</Button>
|
||||||
</AboutInfoDialog>
|
</AboutInfoDialog>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Button } from '@renderer/components/ui/button'
|
import { Button } from '@renderer/components/ui/button'
|
||||||
import { useTheme } from '@renderer/providers/ThemeProvider'
|
import { useTheme } from '@renderer/providers/ThemeProvider'
|
||||||
import { Moon, Sun, SunMoon } from 'lucide-react'
|
import { Moon, Sun, SunMoon } from 'lucide-react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
export default function ThemeToggle() {
|
export default function ThemeToggle() {
|
||||||
|
const { t } = useTranslation()
|
||||||
const { themeSetting, setThemeSetting } = useTheme()
|
const { themeSetting, setThemeSetting } = useTheme()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -12,7 +14,7 @@ export default function ThemeToggle() {
|
|||||||
variant="titlebar"
|
variant="titlebar"
|
||||||
size="titlebar"
|
size="titlebar"
|
||||||
onClick={() => setThemeSetting('light')}
|
onClick={() => setThemeSetting('light')}
|
||||||
title="switch to light theme"
|
title={t('switch to light theme')}
|
||||||
>
|
>
|
||||||
<SunMoon />
|
<SunMoon />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -21,7 +23,7 @@ export default function ThemeToggle() {
|
|||||||
variant="titlebar"
|
variant="titlebar"
|
||||||
size="titlebar"
|
size="titlebar"
|
||||||
onClick={() => setThemeSetting('dark')}
|
onClick={() => setThemeSetting('dark')}
|
||||||
title="switch to dark theme"
|
title={t('switch to dark theme')}
|
||||||
>
|
>
|
||||||
<Sun />
|
<Sun />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -30,7 +32,7 @@ export default function ThemeToggle() {
|
|||||||
variant="titlebar"
|
variant="titlebar"
|
||||||
size="titlebar"
|
size="titlebar"
|
||||||
onClick={() => setThemeSetting('system')}
|
onClick={() => setThemeSetting('system')}
|
||||||
title="switch to system theme"
|
title={t('switch to system theme')}
|
||||||
>
|
>
|
||||||
<Moon />
|
<Moon />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
49
src/renderer/src/i18n/en.ts
Normal file
49
src/renderer/src/i18n/en.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
export default {
|
||||||
|
translation: {
|
||||||
|
'Welcome! 🥳': 'Welcome! 🥳',
|
||||||
|
About: 'About',
|
||||||
|
'New post': 'New post',
|
||||||
|
Post: 'Post',
|
||||||
|
'Relay settings': 'Relay settings',
|
||||||
|
SidebarRelays: 'Relays',
|
||||||
|
Refresh: 'Refresh',
|
||||||
|
Profile: 'Profile',
|
||||||
|
Logout: 'Logout',
|
||||||
|
Following: 'Following',
|
||||||
|
reposted: 'reposted',
|
||||||
|
'just now': 'just now',
|
||||||
|
'n minutes ago': '{{n}} minutes ago',
|
||||||
|
'n hours ago': '{{n}} hours ago',
|
||||||
|
'n days ago': '{{n}} days ago',
|
||||||
|
date: '{{timestamp, date}}',
|
||||||
|
Follow: 'Follow',
|
||||||
|
Unfollow: 'Unfollow',
|
||||||
|
'Follow failed': 'Follow failed',
|
||||||
|
'Unfollow failed': 'Unfollow failed',
|
||||||
|
'show new notes': 'show new notes',
|
||||||
|
'loading...': 'loading...',
|
||||||
|
'no more notes': 'no more notes',
|
||||||
|
'reply to': 'reply to',
|
||||||
|
reply: 'reply',
|
||||||
|
Reply: 'Reply',
|
||||||
|
'load more older replies': 'load more older replies',
|
||||||
|
'Write something...': 'Write something...',
|
||||||
|
Cancel: 'Cancel',
|
||||||
|
Mentions: 'Mentions',
|
||||||
|
'Failed to post': 'Failed to post',
|
||||||
|
'Post successful': 'Post successful',
|
||||||
|
'Your post has been published': 'Your post has been published',
|
||||||
|
Repost: 'Repost',
|
||||||
|
Quote: 'Quote',
|
||||||
|
'copy embedded code': 'copy embedded code',
|
||||||
|
'raw event': 'raw event',
|
||||||
|
Like: 'Like',
|
||||||
|
'switch to light theme': 'switch to light theme',
|
||||||
|
'switch to dark theme': 'switch to dark theme',
|
||||||
|
'switch to system theme': 'switch to system theme',
|
||||||
|
note: 'note',
|
||||||
|
"username's following": "{{username}}'s following",
|
||||||
|
following: 'following',
|
||||||
|
Login: 'Login'
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/renderer/src/i18n/index.ts
Normal file
32
src/renderer/src/i18n/index.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import i18n from 'i18next'
|
||||||
|
import { initReactI18next } from 'react-i18next'
|
||||||
|
import LanguageDetector from 'i18next-browser-languagedetector'
|
||||||
|
import en from './en'
|
||||||
|
import zh from './zh'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
|
const resources = {
|
||||||
|
en,
|
||||||
|
zh
|
||||||
|
}
|
||||||
|
|
||||||
|
i18n
|
||||||
|
.use(LanguageDetector)
|
||||||
|
.use(initReactI18next)
|
||||||
|
.init({
|
||||||
|
fallbackLng: 'en',
|
||||||
|
resources,
|
||||||
|
interpolation: {
|
||||||
|
escapeValue: false // react already safes from xss
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
i18n.services.formatter?.add('date', (value, lng) => {
|
||||||
|
console.log('lng', lng)
|
||||||
|
if (lng?.startsWith('zh')) {
|
||||||
|
return dayjs(value).format('YYYY-MM-DD')
|
||||||
|
}
|
||||||
|
return dayjs(value).format('MMM D, YYYY')
|
||||||
|
})
|
||||||
|
|
||||||
|
export default i18n
|
||||||
49
src/renderer/src/i18n/zh.ts
Normal file
49
src/renderer/src/i18n/zh.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
export default {
|
||||||
|
translation: {
|
||||||
|
'Welcome! 🥳': '欢迎!🥳',
|
||||||
|
About: '关于',
|
||||||
|
'New post': '发布新笔记',
|
||||||
|
Post: '发布笔记',
|
||||||
|
'Relay settings': '中继设置',
|
||||||
|
SidebarRelays: '中继设置',
|
||||||
|
Refresh: '刷新列表',
|
||||||
|
Profile: '个人资料',
|
||||||
|
Logout: '退出登录',
|
||||||
|
Following: '关注',
|
||||||
|
reposted: '转发',
|
||||||
|
'just now': '刚刚',
|
||||||
|
'n minutes ago': '{{n}} 分钟前',
|
||||||
|
'n hours ago': '{{n}} 小时前',
|
||||||
|
'n days ago': '{{n}} 天前',
|
||||||
|
date: '{{timestamp, date}}',
|
||||||
|
Follow: '关注',
|
||||||
|
Unfollow: '取消关注',
|
||||||
|
'Follow failed': '关注失败',
|
||||||
|
'Unfollow failed': '取消关注失败',
|
||||||
|
'show new notes': '显示新笔记',
|
||||||
|
'loading...': '加载中...',
|
||||||
|
'no more notes': '到底了',
|
||||||
|
'reply to': '回复',
|
||||||
|
reply: '回复',
|
||||||
|
Reply: '回复',
|
||||||
|
'load more older replies': '加载更多早期回复',
|
||||||
|
'Write something...': '写点什么...',
|
||||||
|
Cancel: '取消',
|
||||||
|
Mentions: '提及',
|
||||||
|
'Failed to post': '发布失败',
|
||||||
|
'Post successful': '发布成功',
|
||||||
|
'Your post has been published': '您的笔记已发布',
|
||||||
|
Repost: '转发',
|
||||||
|
Quote: '引用',
|
||||||
|
'copy embedded code': '复制嵌入代码',
|
||||||
|
'raw event': '原始事件',
|
||||||
|
Like: '点赞',
|
||||||
|
'switch to light theme': '切换到浅色主题',
|
||||||
|
'switch to dark theme': '切换到深色主题',
|
||||||
|
'switch to system theme': '切换到系统主题',
|
||||||
|
note: '笔记',
|
||||||
|
"username's following": '{{username}} 的关注',
|
||||||
|
following: '关注',
|
||||||
|
Login: '登录'
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,28 +1,30 @@
|
|||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
export function formatTimestamp(timestamp: number) {
|
export function formatTimestamp(timestamp: number) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const time = dayjs(timestamp * 1000)
|
const time = dayjs(timestamp * 1000)
|
||||||
const now = dayjs()
|
const now = dayjs()
|
||||||
|
|
||||||
const diffMonth = now.diff(time, 'month')
|
const diffMonth = now.diff(time, 'month')
|
||||||
if (diffMonth >= 1) {
|
if (diffMonth >= 1) {
|
||||||
return time.format('MMM D, YYYY')
|
return t('date', { timestamp: time.valueOf() })
|
||||||
}
|
}
|
||||||
|
|
||||||
const diffDay = now.diff(time, 'day')
|
const diffDay = now.diff(time, 'day')
|
||||||
if (diffDay >= 1) {
|
if (diffDay >= 1) {
|
||||||
return `${diffDay} days ago`
|
return t('n days ago', { n: diffDay })
|
||||||
}
|
}
|
||||||
|
|
||||||
const diffHour = now.diff(time, 'hour')
|
const diffHour = now.diff(time, 'hour')
|
||||||
if (diffHour >= 1) {
|
if (diffHour >= 1) {
|
||||||
return `${diffHour} hours ago`
|
return t('n hours ago', { n: diffHour })
|
||||||
}
|
}
|
||||||
|
|
||||||
const diffMinute = now.diff(time, 'minute')
|
const diffMinute = now.diff(time, 'minute')
|
||||||
if (diffMinute >= 1) {
|
if (diffMinute >= 1) {
|
||||||
return `${diffMinute} minutes ago`
|
return t('n minutes ago', { n: diffMinute })
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'just now'
|
return t('just now')
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import './assets/main.css'
|
import './assets/main.css'
|
||||||
|
import './i18n'
|
||||||
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import Username from '@renderer/components/Username'
|
|||||||
import { useFetchFollowings, useFetchProfile } from '@renderer/hooks'
|
import { useFetchFollowings, useFetchProfile } from '@renderer/hooks'
|
||||||
import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout'
|
import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
export default function FollowingListPage({ id }: { id?: string }) {
|
export default function FollowingListPage({ id }: { id?: string }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const { profile } = useFetchProfile(id)
|
const { profile } = useFetchProfile(id)
|
||||||
const { followings } = useFetchFollowings(profile?.pubkey)
|
const { followings } = useFetchFollowings(profile?.pubkey)
|
||||||
const [visibleFollowings, setVisibleFollowings] = useState<string[]>([])
|
const [visibleFollowings, setVisibleFollowings] = useState<string[]>([])
|
||||||
@@ -46,7 +48,11 @@ export default function FollowingListPage({ id }: { id?: string }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SecondaryPageLayout
|
<SecondaryPageLayout
|
||||||
titlebarContent={profile?.username ? `${profile.username}'s following` : 'following'}
|
titlebarContent={
|
||||||
|
profile?.username
|
||||||
|
? t("username's following", { username: profile.username })
|
||||||
|
: t('following')
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{visibleFollowings.map((pubkey, index) => (
|
{visibleFollowings.map((pubkey, index) => (
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout'
|
import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
|
const { t } = useTranslation()
|
||||||
return (
|
return (
|
||||||
<SecondaryPageLayout hideBackButton>
|
<SecondaryPageLayout hideBackButton>
|
||||||
<div className="text-muted-foreground w-full h-full flex items-center justify-center">
|
<div className="text-muted-foreground w-full h-full flex items-center justify-center">
|
||||||
Welcome! 🥳
|
{t('Welcome! 🥳')}
|
||||||
</div>
|
</div>
|
||||||
</SecondaryPageLayout>
|
</SecondaryPageLayout>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ import { getParentEventId, getRootEventId } from '@renderer/lib/event'
|
|||||||
import { toNote } from '@renderer/lib/link'
|
import { toNote } from '@renderer/lib/link'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import NotFoundPage from '../NotFoundPage'
|
import NotFoundPage from '../NotFoundPage'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
export default function NotePage({ id }: { id?: string }) {
|
export default function NotePage({ id }: { id?: string }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const { event, isFetching } = useFetchEventById(id)
|
const { event, isFetching } = useFetchEventById(id)
|
||||||
const parentEventId = useMemo(() => getParentEventId(event), [event])
|
const parentEventId = useMemo(() => getParentEventId(event), [event])
|
||||||
const rootEventId = useMemo(() => getRootEventId(event), [event])
|
const rootEventId = useMemo(() => getRootEventId(event), [event])
|
||||||
@@ -28,7 +30,7 @@ export default function NotePage({ id }: { id?: string }) {
|
|||||||
if (!event) return <NotFoundPage />
|
if (!event) return <NotFoundPage />
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SecondaryPageLayout titlebarContent="note">
|
<SecondaryPageLayout titlebarContent={t('note')}>
|
||||||
<ParentNote key={`root-note-${event.id}`} eventId={rootEventId} />
|
<ParentNote key={`root-note-${event.id}`} eventId={rootEventId} />
|
||||||
<ParentNote key={`parent-note-${event.id}`} eventId={parentEventId} />
|
<ParentNote key={`parent-note-${event.id}`} eventId={parentEventId} />
|
||||||
<Note key={`note-${event.id}`} event={event} fetchNoteStats />
|
<Note key={`note-${event.id}`} event={event} fetchNoteStats />
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import ProfileAbout from '@renderer/components/ProfileAbout'
|
|||||||
import ProfileBanner from '@renderer/components/ProfileBanner'
|
import ProfileBanner from '@renderer/components/ProfileBanner'
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@renderer/components/ui/avatar'
|
import { Avatar, AvatarFallback, AvatarImage } from '@renderer/components/ui/avatar'
|
||||||
import { Separator } from '@renderer/components/ui/separator'
|
import { Separator } from '@renderer/components/ui/separator'
|
||||||
|
import { Skeleton } from '@renderer/components/ui/skeleton'
|
||||||
import { useFetchFollowings, useFetchProfile } from '@renderer/hooks'
|
import { useFetchFollowings, useFetchProfile } from '@renderer/hooks'
|
||||||
import { useFetchRelayList } from '@renderer/hooks/useFetchRelayList'
|
import { useFetchRelayList } from '@renderer/hooks/useFetchRelayList'
|
||||||
import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout'
|
import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout'
|
||||||
@@ -14,13 +15,13 @@ import { SecondaryPageLink } from '@renderer/PageManager'
|
|||||||
import { useFollowList } from '@renderer/providers/FollowListProvider'
|
import { useFollowList } from '@renderer/providers/FollowListProvider'
|
||||||
import { useNostr } from '@renderer/providers/NostrProvider'
|
import { useNostr } from '@renderer/providers/NostrProvider'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
|
import NotFoundPage from '../NotFoundPage'
|
||||||
import PubkeyCopy from './PubkeyCopy'
|
import PubkeyCopy from './PubkeyCopy'
|
||||||
import QrCodePopover from './QrCodePopover'
|
import QrCodePopover from './QrCodePopover'
|
||||||
import LoadingPage from '../LoadingPage'
|
import { useTranslation } from 'react-i18next'
|
||||||
import NotFoundPage from '../NotFoundPage'
|
|
||||||
import { Skeleton } from '@renderer/components/ui/skeleton'
|
|
||||||
|
|
||||||
export default function ProfilePage({ id }: { id?: string }) {
|
export default function ProfilePage({ id }: { id?: string }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const { profile, isFetching } = useFetchProfile(id)
|
const { profile, isFetching } = useFetchProfile(id)
|
||||||
const relayList = useFetchRelayList(profile?.pubkey)
|
const relayList = useFetchRelayList(profile?.pubkey)
|
||||||
const { pubkey: accountPubkey } = useNostr()
|
const { pubkey: accountPubkey } = useNostr()
|
||||||
@@ -85,10 +86,10 @@ export default function ProfilePage({ id }: { id?: string }) {
|
|||||||
<ProfileAbout about={about} className="text-wrap break-words whitespace-pre-wrap mt-2" />
|
<ProfileAbout about={about} className="text-wrap break-words whitespace-pre-wrap mt-2" />
|
||||||
<SecondaryPageLink
|
<SecondaryPageLink
|
||||||
to={toFollowingList(pubkey)}
|
to={toFollowingList(pubkey)}
|
||||||
className="mt-2 flex gap-1 hover:underline text-sm"
|
className="mt-2 flex gap-1 hover:underline text-sm w-fit"
|
||||||
>
|
>
|
||||||
{isSelf ? selfFollowings.length : followings.length}
|
{isSelf ? selfFollowings.length : followings.length}
|
||||||
<div className="text-muted-foreground">Following</div>
|
<div className="text-muted-foreground">{t('Following')}</div>
|
||||||
</SecondaryPageLink>
|
</SecondaryPageLink>
|
||||||
</div>
|
</div>
|
||||||
<Separator className="my-4" />
|
<Separator className="my-4" />
|
||||||
|
|||||||
Reference in New Issue
Block a user