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",
|
||||
"dayjs": "^1.11.13",
|
||||
"framer-motion": "^11.11.17",
|
||||
"i18next": "^23.16.5",
|
||||
"i18next-browser-languagedetector": "^8.0.0",
|
||||
"lru-cache": "^11.0.1",
|
||||
"lucide-react": "^0.453.0",
|
||||
"nostr-tools": "^2.9.1",
|
||||
"path-to-regexp": "^8.2.0",
|
||||
"qrcode.react": "^4.1.0",
|
||||
"react-i18next": "^15.1.1",
|
||||
"react-resizable-panels": "^2.1.5",
|
||||
"react-string-replace": "^1.1.1",
|
||||
"tailwind-merge": "^2.5.4",
|
||||
@@ -340,6 +343,17 @@
|
||||
"@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": {
|
||||
"version": "7.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz",
|
||||
@@ -6732,6 +6746,14 @@
|
||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||
"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": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz",
|
||||
@@ -6776,6 +6798,36 @@
|
||||
"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": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz",
|
||||
@@ -8587,6 +8639,27 @@
|
||||
"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": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
@@ -8781,6 +8854,11 @@
|
||||
"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": {
|
||||
"version": "1.5.3",
|
||||
"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": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
||||
@@ -50,11 +50,14 @@
|
||||
"dataloader": "^2.2.2",
|
||||
"dayjs": "^1.11.13",
|
||||
"framer-motion": "^11.11.17",
|
||||
"i18next": "^23.16.5",
|
||||
"i18next-browser-languagedetector": "^8.0.0",
|
||||
"lru-cache": "^11.0.1",
|
||||
"lucide-react": "^0.453.0",
|
||||
"nostr-tools": "^2.9.1",
|
||||
"path-to-regexp": "^8.2.0",
|
||||
"qrcode.react": "^4.1.0",
|
||||
"react-i18next": "^15.1.1",
|
||||
"react-resizable-panels": "^2.1.5",
|
||||
"react-string-replace": "^1.1.1",
|
||||
"tailwind-merge": "^2.5.4",
|
||||
|
||||
@@ -11,6 +11,7 @@ import { toProfile } from '@renderer/lib/link'
|
||||
import { generateImageByPubkey } from '@renderer/lib/pubkey'
|
||||
import { useSecondaryPage } from '@renderer/PageManager'
|
||||
import { useNostr } from '@renderer/providers/NostrProvider'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function ProfileButton({
|
||||
pubkey,
|
||||
@@ -19,6 +20,7 @@ export default function ProfileButton({
|
||||
pubkey: string
|
||||
variant?: 'titlebar' | 'sidebar'
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { logout } = useNostr()
|
||||
const { profile } = useFetchProfile(pubkey)
|
||||
const { push } = useSecondaryPage()
|
||||
@@ -61,9 +63,9 @@ export default function ProfileButton({
|
||||
{triggerComponent}
|
||||
</DropdownMenuTrigger>
|
||||
<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}>
|
||||
Logout
|
||||
{t('Logout')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -4,8 +4,10 @@ import { useFollowList } from '@renderer/providers/FollowListProvider'
|
||||
import { useNostr } from '@renderer/providers/NostrProvider'
|
||||
import { Loader } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function FollowButton({ pubkey }: { pubkey: string }) {
|
||||
const { t } = useTranslation()
|
||||
const { toast } = useToast()
|
||||
const { pubkey: accountPubkey, checkLogin } = useNostr()
|
||||
const { followListEvent, followings, isReady, follow, unfollow } = useFollowList()
|
||||
@@ -24,7 +26,7 @@ export default function FollowButton({ pubkey }: { pubkey: string }) {
|
||||
await follow(pubkey)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Follow failed',
|
||||
title: t('Follow failed'),
|
||||
description: (error as Error).message,
|
||||
variant: 'destructive'
|
||||
})
|
||||
@@ -44,7 +46,7 @@ export default function FollowButton({ pubkey }: { pubkey: string }) {
|
||||
await unfollow(pubkey)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Unfollow failed',
|
||||
title: t('Unfollow failed'),
|
||||
description: (error as Error).message,
|
||||
variant: 'destructive'
|
||||
})
|
||||
@@ -61,11 +63,11 @@ export default function FollowButton({ pubkey }: { pubkey: string }) {
|
||||
onClick={handleUnfollow}
|
||||
disabled={updating}
|
||||
>
|
||||
{updating ? <Loader className="animate-spin" /> : 'Unfollow'}
|
||||
{updating ? <Loader className="animate-spin" /> : t('Unfollow')}
|
||||
</Button>
|
||||
) : (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { Input } from '@renderer/components/ui/input'
|
||||
import { useNostr } from '@renderer/providers/NostrProvider'
|
||||
import { Dispatch, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function LoginDialog({
|
||||
open,
|
||||
@@ -17,6 +18,7 @@ export default function LoginDialog({
|
||||
open: boolean
|
||||
setOpen: Dispatch<boolean>
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { login, canLogin } = useNostr()
|
||||
const [nsec, setNsec] = useState('')
|
||||
const [errMsg, setErrMsg] = useState<string | null>(null)
|
||||
@@ -40,7 +42,7 @@ export default function LoginDialog({
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="w-80">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Sign in</DialogTitle>
|
||||
<DialogTitle />
|
||||
<DialogDescription className="text-destructive">
|
||||
{!canLogin && 'Encryption is not available in your device.'}
|
||||
</DialogDescription>
|
||||
@@ -57,7 +59,7 @@ export default function LoginDialog({
|
||||
{errMsg && <div className="text-xs text-destructive pl-3">{errMsg}</div>}
|
||||
</div>
|
||||
<Button onClick={handleLogin} disabled={!canLogin}>
|
||||
Login
|
||||
{t('Login')}
|
||||
</Button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -3,18 +3,25 @@ import { Repeat2 } from 'lucide-react'
|
||||
import { Event, kinds, verifyEvent } from 'nostr-tools'
|
||||
import Username from '../Username'
|
||||
import ShortTextNoteCard from './ShortTextNoteCard'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
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
|
||||
try {
|
||||
if (!targetEvent || !verifyEvent(targetEvent) || targetEvent.kind !== kinds.ShortTextNote) {
|
||||
return null
|
||||
}
|
||||
client.addEventToCache(targetEvent)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
client.addEventToCache(targetEvent)
|
||||
return targetEvent
|
||||
}, [event])
|
||||
if (!targetEvent) return null
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
@@ -25,7 +32,7 @@ export default function RepostNoteCard({ event, className }: { event: Event; cla
|
||||
className="font-semibold truncate"
|
||||
skeletonClassName="h-3"
|
||||
/>
|
||||
<div>reposted</div>
|
||||
<div>{t('reposted')}</div>
|
||||
</div>
|
||||
<ShortTextNoteCard event={targetEvent} />
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import dayjs from 'dayjs'
|
||||
import { Event, Filter, kinds } from 'nostr-tools'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import NoteCard from '../NoteCard'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function NoteList({
|
||||
relayUrls,
|
||||
@@ -17,6 +18,7 @@ export default function NoteList({
|
||||
filter?: Filter
|
||||
className?: string
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { isReady, singEvent } = useNostr()
|
||||
const [events, setEvents] = useState<Event[]>([])
|
||||
const [newEvents, setNewEvents] = useState<Event[]>([])
|
||||
@@ -127,7 +129,7 @@ export default function NoteList({
|
||||
{newEvents.length > 0 && (
|
||||
<div className="flex justify-center w-full mb-4">
|
||||
<Button size="lg" onClick={showNewEvents}>
|
||||
show new notes
|
||||
{t('show new notes')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -137,7 +139,7 @@ export default function NoteList({
|
||||
))}
|
||||
</div>
|
||||
<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>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Heart, Loader } from 'lucide-react'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { formatCount } from './utils'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function LikeButton({
|
||||
event,
|
||||
@@ -17,6 +18,7 @@ export default function LikeButton({
|
||||
variant?: 'normal' | 'reply'
|
||||
canFetch?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { publish, checkLogin } = useNostr()
|
||||
const { noteStatsMap, fetchNoteLikedStatus, fetchNoteLikeCount, markNoteAsLiked } = useNoteStats()
|
||||
const [liking, setLiking] = useState(false)
|
||||
@@ -74,7 +76,7 @@ export default function LikeButton({
|
||||
)}
|
||||
onClick={like}
|
||||
disabled={!canLike}
|
||||
title="like"
|
||||
title={t('Like')}
|
||||
>
|
||||
{liking ? (
|
||||
<Loader className="animate-spin" size={16} />
|
||||
|
||||
@@ -9,8 +9,10 @@ import { Code, Copy, Ellipsis } from 'lucide-react'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useState } from 'react'
|
||||
import RawEventDialog from './RawEventDialog'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function NoteOptions({ event }: { event: Event }) {
|
||||
const { t } = useTranslation()
|
||||
const [isRawEventDialogOpen, setIsRawEventDialogOpen] = useState(false)
|
||||
|
||||
return (
|
||||
@@ -30,7 +32,7 @@ export default function NoteOptions({ event }: { event: Event }) {
|
||||
}}
|
||||
>
|
||||
<Copy />
|
||||
copy embedded code
|
||||
{t('copy embedded code')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
@@ -39,7 +41,7 @@ export default function NoteOptions({ event }: { event: Event }) {
|
||||
}}
|
||||
>
|
||||
<Code />
|
||||
raw event
|
||||
{t('raw event')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -5,8 +5,10 @@ import { Event } from 'nostr-tools'
|
||||
import { useMemo, useState } from 'react'
|
||||
import PostDialog from '../PostDialog'
|
||||
import { formatCount } from './utils'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function ReplyButton({ event }: { event: Event }) {
|
||||
const { t } = useTranslation()
|
||||
const { noteStatsMap } = useNoteStats()
|
||||
const { pubkey } = useNostr()
|
||||
const { replyCount } = useMemo(() => noteStatsMap.get(event.id) ?? {}, [noteStatsMap, event.id])
|
||||
@@ -21,6 +23,7 @@ export default function ReplyButton({ event }: { event: Event }) {
|
||||
e.stopPropagation()
|
||||
setOpen(true)
|
||||
}}
|
||||
title={t('Reply')}
|
||||
>
|
||||
<MessageCircle size={16} />
|
||||
<div className="text-sm">{formatCount(replyCount)}</div>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Event } from 'nostr-tools'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import PostDialog from '../PostDialog'
|
||||
import { formatCount } from './utils'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function RepostButton({
|
||||
event,
|
||||
@@ -23,6 +24,7 @@ export default function RepostButton({
|
||||
event: Event
|
||||
canFetch?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { publish, checkLogin } = useNostr()
|
||||
const { noteStatsMap, fetchNoteRepostCount, fetchNoteRepostedStatus, markNoteAsReposted } =
|
||||
useNoteStats()
|
||||
@@ -84,7 +86,7 @@ export default function RepostButton({
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
disabled={!canRepost}
|
||||
title="repost"
|
||||
title={t('Repost')}
|
||||
>
|
||||
{reposting ? <Loader className="animate-spin" size={16} /> : <Repeat size={16} />}
|
||||
<div className="text-sm">{formatCount(repostCount)}</div>
|
||||
@@ -97,7 +99,7 @@ export default function RepostButton({
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItem onClick={repost}>
|
||||
<Repeat /> Repost
|
||||
<Repeat /> {t('Repost')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
@@ -105,7 +107,7 @@ export default function RepostButton({
|
||||
setIsPostDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<PencilLine /> Quote
|
||||
<PencilLine /> {t('Quote')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Event } from 'nostr-tools'
|
||||
import UserAvatar from '../UserAvatar'
|
||||
import { cn } from '@renderer/lib/utils'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function ParentNotePreview({
|
||||
event,
|
||||
@@ -11,6 +12,7 @@ export default function ParentNotePreview({
|
||||
className?: string
|
||||
onClick?: React.MouseEventHandler<HTMLDivElement> | undefined
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -19,7 +21,7 @@ export default function ParentNotePreview({
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="shrink-0">reply to</div>
|
||||
<div className="shrink-0">{t('reply to')}</div>
|
||||
<UserAvatar userId={event.pubkey} size="tiny" />
|
||||
<div className="truncate">{event.content}</div>
|
||||
</div>
|
||||
|
||||
@@ -2,8 +2,10 @@ import PostDialog from '@renderer/components/PostDialog'
|
||||
import { Button } from '@renderer/components/ui/button'
|
||||
import { PencilLine } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function PostButton({ variant = 'titlebar' }: { variant?: 'titlebar' | 'sidebar' }) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
@@ -11,14 +13,14 @@ export default function PostButton({ variant = 'titlebar' }: { variant?: 'titleb
|
||||
<Button
|
||||
variant={variant}
|
||||
size={variant}
|
||||
title="new post"
|
||||
title={t('New post')}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setOpen(true)
|
||||
}}
|
||||
>
|
||||
<PencilLine />
|
||||
{variant === 'sidebar' && <div>Post</div>}
|
||||
{variant === 'sidebar' && <div>{t('Post')}</div>}
|
||||
</Button>
|
||||
<PostDialog open={open} setOpen={setOpen} />
|
||||
</>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Event } from 'nostr-tools'
|
||||
import { useEffect, useState } from 'react'
|
||||
import UserAvatar from '../UserAvatar'
|
||||
import Username from '../Username'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function Mentions({
|
||||
content,
|
||||
@@ -14,6 +15,7 @@ export default function Mentions({
|
||||
content: string
|
||||
parentEvent?: Event
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { pubkey } = useNostr()
|
||||
const [pubkeys, setPubkeys] = useState<string[]>([])
|
||||
|
||||
@@ -32,7 +34,7 @@ export default function Mentions({
|
||||
disabled={pubkeys.length === 0}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Mentions {pubkeys.length > 0 && `(${pubkeys.length})`}
|
||||
{t('Mentions')} {pubkeys.length > 0 && `(${pubkeys.length})`}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-48">
|
||||
|
||||
@@ -19,6 +19,7 @@ import UserAvatar from '../UserAvatar'
|
||||
import Mentions from './Metions'
|
||||
import Preview from './Preview'
|
||||
import Uploader from './Uploader'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function PostDialog({
|
||||
defaultContent = '',
|
||||
@@ -31,6 +32,7 @@ export default function PostDialog({
|
||||
open: boolean
|
||||
setOpen: Dispatch<boolean>
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { toast } = useToast()
|
||||
const { publish, checkLogin } = useNostr()
|
||||
const [content, setContent] = useState(defaultContent)
|
||||
@@ -65,14 +67,14 @@ export default function PostDialog({
|
||||
error.errors.forEach((e) =>
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Failed to post',
|
||||
title: t('Failed to post'),
|
||||
description: e.message
|
||||
})
|
||||
)
|
||||
} else if (error instanceof Error) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Failed to post',
|
||||
title: t('Failed to post'),
|
||||
description: error.message
|
||||
})
|
||||
}
|
||||
@@ -82,8 +84,8 @@ export default function PostDialog({
|
||||
setPosting(false)
|
||||
}
|
||||
toast({
|
||||
title: 'Post successful',
|
||||
description: 'Your post has been published'
|
||||
title: t('Post successful'),
|
||||
description: t('Your post has been published')
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -102,7 +104,7 @@ export default function PostDialog({
|
||||
<div className="truncate">{parentEvent.content}</div>
|
||||
</div>
|
||||
) : (
|
||||
'New post'
|
||||
t('New post')
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
@@ -111,7 +113,7 @@ export default function PostDialog({
|
||||
className="h-32"
|
||||
onChange={handleTextareaChange}
|
||||
value={content}
|
||||
placeholder="Write something..."
|
||||
placeholder={t('Write something...')}
|
||||
/>
|
||||
{content && <Preview content={content} />}
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -125,11 +127,11 @@ export default function PostDialog({
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={!canPost} onClick={post}>
|
||||
{posting && <LoaderCircle className="animate-spin" />}
|
||||
Post
|
||||
{t('Post')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import { Button } from '@renderer/components/ui/button'
|
||||
import { usePrimaryPage } from '@renderer/PageManager'
|
||||
import { RefreshCcw } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function RefreshButton({
|
||||
variant = 'titlebar'
|
||||
}: {
|
||||
variant?: 'titlebar' | 'sidebar'
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { refresh } = usePrimaryPage()
|
||||
return (
|
||||
<Button variant={variant} size={variant} onClick={refresh} title="reload">
|
||||
<Button variant={variant} size={variant} onClick={refresh} title={t('Refresh')}>
|
||||
<RefreshCcw />
|
||||
{variant === 'sidebar' && <div>Refresh</div>}
|
||||
{variant === 'sidebar' && <div>{t('Refresh')}</div>}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,18 +3,20 @@ import { Button } from '@renderer/components/ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'
|
||||
import { ScrollArea } from '@renderer/components/ui/scroll-area'
|
||||
import { Server } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function RelaySettingsPopover({
|
||||
variant = 'titlebar'
|
||||
}: {
|
||||
variant?: 'titlebar' | 'sidebar'
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant={variant} size={variant} title="relay settings">
|
||||
<Button variant={variant} size={variant} title={t('Relay settings')}>
|
||||
<Server />
|
||||
{variant === 'sidebar' && <div>Relays</div>}
|
||||
{variant === 'sidebar' && <div>{t('SidebarRelays')}</div>}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
|
||||
@@ -7,6 +7,7 @@ import ParentNotePreview from '../ParentNotePreview'
|
||||
import PostDialog from '../PostDialog'
|
||||
import UserAvatar from '../UserAvatar'
|
||||
import Username from '../Username'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function ReplyNote({
|
||||
event,
|
||||
@@ -19,6 +20,7 @@ export default function ReplyNote({
|
||||
onClickParent?: (eventId: string) => void
|
||||
highlight?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [isPostDialogOpen, setIsPostDialogOpen] = useState(false)
|
||||
|
||||
return (
|
||||
@@ -42,7 +44,7 @@ export default function ReplyNote({
|
||||
className="text-muted-foreground hover:text-primary cursor-pointer"
|
||||
onClick={() => setIsPostDialogOpen(true)}
|
||||
>
|
||||
reply
|
||||
{t('reply')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,8 +8,10 @@ import dayjs from 'dayjs'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import ReplyNote from '../ReplyNote'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function ReplyNoteList({ event, className }: { event: Event; className?: string }) {
|
||||
const { t } = useTranslation()
|
||||
const [replies, setReplies] = useState<Event[]>([])
|
||||
const [replyMap, setReplyMap] = useState<
|
||||
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' : ''}`}
|
||||
onClick={loadMore}
|
||||
>
|
||||
{loading ? 'loading...' : hasMore ? 'load more older replies' : null}
|
||||
{loading ? t('loading...') : hasMore ? t('load more older replies') : null}
|
||||
</div>
|
||||
{replies.length > 0 && (loading || hasMore) && <Separator className="my-4" />}
|
||||
<div className={cn('mb-4', className)}>
|
||||
|
||||
@@ -8,8 +8,10 @@ import AccountButton from '../AccountButton'
|
||||
import PostButton from '../PostButton'
|
||||
import RefreshButton from '../RefreshButton'
|
||||
import RelaySettingsPopover from '../RelaySettingsPopover'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function PrimaryPageSidebar() {
|
||||
const { t } = useTranslation()
|
||||
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="space-y-2">
|
||||
@@ -23,7 +25,7 @@ export default function PrimaryPageSidebar() {
|
||||
<AboutInfoDialog>
|
||||
<Button variant="sidebar" size="sidebar">
|
||||
<Info />
|
||||
About
|
||||
{t('About')}
|
||||
</Button>
|
||||
</AboutInfoDialog>
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Button } from '@renderer/components/ui/button'
|
||||
import { useTheme } from '@renderer/providers/ThemeProvider'
|
||||
import { Moon, Sun, SunMoon } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const { t } = useTranslation()
|
||||
const { themeSetting, setThemeSetting } = useTheme()
|
||||
|
||||
return (
|
||||
@@ -12,7 +14,7 @@ export default function ThemeToggle() {
|
||||
variant="titlebar"
|
||||
size="titlebar"
|
||||
onClick={() => setThemeSetting('light')}
|
||||
title="switch to light theme"
|
||||
title={t('switch to light theme')}
|
||||
>
|
||||
<SunMoon />
|
||||
</Button>
|
||||
@@ -21,7 +23,7 @@ export default function ThemeToggle() {
|
||||
variant="titlebar"
|
||||
size="titlebar"
|
||||
onClick={() => setThemeSetting('dark')}
|
||||
title="switch to dark theme"
|
||||
title={t('switch to dark theme')}
|
||||
>
|
||||
<Sun />
|
||||
</Button>
|
||||
@@ -30,7 +32,7 @@ export default function ThemeToggle() {
|
||||
variant="titlebar"
|
||||
size="titlebar"
|
||||
onClick={() => setThemeSetting('system')}
|
||||
title="switch to system theme"
|
||||
title={t('switch to system theme')}
|
||||
>
|
||||
<Moon />
|
||||
</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 { useTranslation } from 'react-i18next'
|
||||
|
||||
export function formatTimestamp(timestamp: number) {
|
||||
const { t } = useTranslation()
|
||||
const time = dayjs(timestamp * 1000)
|
||||
const now = dayjs()
|
||||
|
||||
const diffMonth = now.diff(time, 'month')
|
||||
if (diffMonth >= 1) {
|
||||
return time.format('MMM D, YYYY')
|
||||
return t('date', { timestamp: time.valueOf() })
|
||||
}
|
||||
|
||||
const diffDay = now.diff(time, 'day')
|
||||
if (diffDay >= 1) {
|
||||
return `${diffDay} days ago`
|
||||
return t('n days ago', { n: diffDay })
|
||||
}
|
||||
|
||||
const diffHour = now.diff(time, 'hour')
|
||||
if (diffHour >= 1) {
|
||||
return `${diffHour} hours ago`
|
||||
return t('n hours ago', { n: diffHour })
|
||||
}
|
||||
|
||||
const diffMinute = now.diff(time, 'minute')
|
||||
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 './i18n'
|
||||
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
|
||||
@@ -5,8 +5,10 @@ import Username from '@renderer/components/Username'
|
||||
import { useFetchFollowings, useFetchProfile } from '@renderer/hooks'
|
||||
import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function FollowingListPage({ id }: { id?: string }) {
|
||||
const { t } = useTranslation()
|
||||
const { profile } = useFetchProfile(id)
|
||||
const { followings } = useFetchFollowings(profile?.pubkey)
|
||||
const [visibleFollowings, setVisibleFollowings] = useState<string[]>([])
|
||||
@@ -46,7 +48,11 @@ export default function FollowingListPage({ id }: { id?: string }) {
|
||||
|
||||
return (
|
||||
<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">
|
||||
{visibleFollowings.map((pubkey, index) => (
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function HomePage() {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<SecondaryPageLayout hideBackButton>
|
||||
<div className="text-muted-foreground w-full h-full flex items-center justify-center">
|
||||
Welcome! 🥳
|
||||
{t('Welcome! 🥳')}
|
||||
</div>
|
||||
</SecondaryPageLayout>
|
||||
)
|
||||
|
||||
@@ -12,8 +12,10 @@ import { getParentEventId, getRootEventId } from '@renderer/lib/event'
|
||||
import { toNote } from '@renderer/lib/link'
|
||||
import { useMemo } from 'react'
|
||||
import NotFoundPage from '../NotFoundPage'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function NotePage({ id }: { id?: string }) {
|
||||
const { t } = useTranslation()
|
||||
const { event, isFetching } = useFetchEventById(id)
|
||||
const parentEventId = useMemo(() => getParentEventId(event), [event])
|
||||
const rootEventId = useMemo(() => getRootEventId(event), [event])
|
||||
@@ -28,7 +30,7 @@ export default function NotePage({ id }: { id?: string }) {
|
||||
if (!event) return <NotFoundPage />
|
||||
|
||||
return (
|
||||
<SecondaryPageLayout titlebarContent="note">
|
||||
<SecondaryPageLayout titlebarContent={t('note')}>
|
||||
<ParentNote key={`root-note-${event.id}`} eventId={rootEventId} />
|
||||
<ParentNote key={`parent-note-${event.id}`} eventId={parentEventId} />
|
||||
<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 { Avatar, AvatarFallback, AvatarImage } from '@renderer/components/ui/avatar'
|
||||
import { Separator } from '@renderer/components/ui/separator'
|
||||
import { Skeleton } from '@renderer/components/ui/skeleton'
|
||||
import { useFetchFollowings, useFetchProfile } from '@renderer/hooks'
|
||||
import { useFetchRelayList } from '@renderer/hooks/useFetchRelayList'
|
||||
import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout'
|
||||
@@ -14,13 +15,13 @@ import { SecondaryPageLink } from '@renderer/PageManager'
|
||||
import { useFollowList } from '@renderer/providers/FollowListProvider'
|
||||
import { useNostr } from '@renderer/providers/NostrProvider'
|
||||
import { useMemo } from 'react'
|
||||
import NotFoundPage from '../NotFoundPage'
|
||||
import PubkeyCopy from './PubkeyCopy'
|
||||
import QrCodePopover from './QrCodePopover'
|
||||
import LoadingPage from '../LoadingPage'
|
||||
import NotFoundPage from '../NotFoundPage'
|
||||
import { Skeleton } from '@renderer/components/ui/skeleton'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function ProfilePage({ id }: { id?: string }) {
|
||||
const { t } = useTranslation()
|
||||
const { profile, isFetching } = useFetchProfile(id)
|
||||
const relayList = useFetchRelayList(profile?.pubkey)
|
||||
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" />
|
||||
<SecondaryPageLink
|
||||
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}
|
||||
<div className="text-muted-foreground">Following</div>
|
||||
<div className="text-muted-foreground">{t('Following')}</div>
|
||||
</SecondaryPageLink>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
|
||||
Reference in New Issue
Block a user