chore: i18n

This commit is contained in:
codytseng
2024-11-19 18:25:56 +08:00
parent 32cc34582d
commit aa4f4258aa
30 changed files with 336 additions and 59 deletions

86
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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>

View File

@@ -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>
)
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
</>
)

View File

@@ -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} />

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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} />
</>

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>
)
}

View File

@@ -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

View File

@@ -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>

View File

@@ -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)}>

View File

@@ -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>
)}

View File

@@ -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>

View 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'
}
}

View 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

View 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: '登录'
}
}

View File

@@ -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')
}

View File

@@ -1,4 +1,5 @@
import './assets/main.css'
import './i18n'
import React from 'react'
import ReactDOM from 'react-dom/client'

View File

@@ -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) => (

View File

@@ -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>
)

View File

@@ -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 />

View File

@@ -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" />