feat: generate new account & profile editor
This commit is contained in:
parent
3f031da748
commit
78629dd64f
33 changed files with 535 additions and 142 deletions
59
src/components/AccountManager/GenerateNewAccount.tsx
Normal file
59
src/components/AccountManager/GenerateNewAccount.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { Check, Copy, RefreshCcw } from 'lucide-react'
|
||||
import { generateSecretKey } from 'nostr-tools'
|
||||
import { nsecEncode } from 'nostr-tools/nip19'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function GenerateNewAccount({
|
||||
back,
|
||||
onLoginSuccess
|
||||
}: {
|
||||
back: () => void
|
||||
onLoginSuccess: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { nsecLogin } = useNostr()
|
||||
const [nsec, setNsec] = useState(generateNsec())
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleLogin = () => {
|
||||
nsecLogin(nsec).then(() => onLoginSuccess())
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="text-orange-400">
|
||||
{t(
|
||||
'This is a private key. Do not share it with anyone. Keep it safe and secure. You will not be able to recover it if you lose it.'
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input value={nsec} />
|
||||
<Button variant="secondary" onClick={() => setNsec(generateNsec())}>
|
||||
<RefreshCcw />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(nsec)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}}
|
||||
>
|
||||
{copied ? <Check /> : <Copy />}
|
||||
</Button>
|
||||
</div>
|
||||
<Button onClick={handleLogin}>{t('Login')}</Button>
|
||||
<Button variant="secondary" onClick={back}>
|
||||
{t('Back')}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function generateNsec() {
|
||||
const sk = generateSecretKey()
|
||||
return nsecEncode(sk)
|
||||
}
|
||||
|
|
@ -1,34 +1,38 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { TSignerType } from '@/types'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AccountList from '../AccountList'
|
||||
import BunkerLogin from './BunkerLogin'
|
||||
import PrivateKeyLogin from './NsecLogin'
|
||||
import GenerateNewAccount from './GenerateNewAccount'
|
||||
|
||||
type TAccountManagerPage = 'nsec' | 'bunker' | 'generate' | null
|
||||
|
||||
export default function AccountManager({ close }: { close?: () => void }) {
|
||||
const [loginMethod, setLoginMethod] = useState<TSignerType | null>(null)
|
||||
const [page, setPage] = useState<TAccountManagerPage>(null)
|
||||
|
||||
return (
|
||||
<>
|
||||
{loginMethod === 'nsec' ? (
|
||||
<PrivateKeyLogin back={() => setLoginMethod(null)} onLoginSuccess={() => close?.()} />
|
||||
) : loginMethod === 'bunker' ? (
|
||||
<BunkerLogin back={() => setLoginMethod(null)} onLoginSuccess={() => close?.()} />
|
||||
{page === 'nsec' ? (
|
||||
<PrivateKeyLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
|
||||
) : page === 'bunker' ? (
|
||||
<BunkerLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
|
||||
) : page === 'generate' ? (
|
||||
<GenerateNewAccount back={() => setPage(null)} onLoginSuccess={() => close?.()} />
|
||||
) : (
|
||||
<AccountManagerNav setLoginMethod={setLoginMethod} close={close} />
|
||||
<AccountManagerNav setPage={setPage} close={close} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function AccountManagerNav({
|
||||
setLoginMethod,
|
||||
setPage,
|
||||
close
|
||||
}: {
|
||||
setLoginMethod: (method: TSignerType) => void
|
||||
setPage: (page: TAccountManagerPage) => void
|
||||
close?: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
|
@ -44,12 +48,19 @@ function AccountManagerNav({
|
|||
{t('Login with Browser Extension')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="secondary" onClick={() => setLoginMethod('bunker')} className="w-full">
|
||||
<Button variant="secondary" onClick={() => setPage('bunker')} className="w-full">
|
||||
{t('Login with Bunker')}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setLoginMethod('nsec')} className="w-full">
|
||||
<Button variant="secondary" onClick={() => setPage('nsec')} className="w-full">
|
||||
{t('Login with Private Key')}
|
||||
</Button>
|
||||
<Separator />
|
||||
<div className="text-center text-muted-foreground text-sm font-semibold">
|
||||
{t("Don't have an account yet?")}
|
||||
</div>
|
||||
<Button variant="secondary" onClick={() => setPage('generate')} className="w-full">
|
||||
{t('Generate New Account')}
|
||||
</Button>
|
||||
{accounts.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ export default function LoginDialog({
|
|||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="w-96 max-h-[90vh] overflow-auto">
|
||||
<DialogContent className="w-[520px] max-h-[90vh] overflow-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="hidden" />
|
||||
<DialogDescription className="hidden" />
|
||||
|
|
|
|||
|
|
@ -6,10 +6,10 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select'
|
||||
import { TMailboxRelay, TMailboxRelayScope } from '@/types'
|
||||
import { CircleX, Server } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { TMailboxRelay, TMailboxRelayScope } from './types'
|
||||
|
||||
export default function MailboxRelay({
|
||||
mailboxRelay,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import { useToast } from '@/hooks'
|
||||
import { createRelayListDraftEvent } from '@/lib/draft-event'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import dayjs from 'dayjs'
|
||||
import { TMailboxRelay } from '@/types'
|
||||
import { CloudUpload, Loader } from 'lucide-react'
|
||||
import { kinds } from 'nostr-tools'
|
||||
import { useState } from 'react'
|
||||
import { Button } from '../ui/button'
|
||||
import { TMailboxRelay } from './types'
|
||||
|
||||
export default function SaveButton({
|
||||
mailboxRelays,
|
||||
|
|
@ -24,14 +23,7 @@ export default function SaveButton({
|
|||
if (!pubkey) return
|
||||
|
||||
setPushing(true)
|
||||
const event = {
|
||||
kind: kinds.RelayList,
|
||||
content: '',
|
||||
tags: mailboxRelays.map(({ url, scope }) =>
|
||||
scope === 'both' ? ['r', url] : ['r', url, scope]
|
||||
),
|
||||
created_at: dayjs().unix()
|
||||
}
|
||||
const event = createRelayListDraftEvent(mailboxRelays)
|
||||
const relayListEvent = await publish(event)
|
||||
updateRelayListEvent(relayListEvent)
|
||||
toast({
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { normalizeUrl } from '@/lib/url'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { TMailboxRelay, TMailboxRelayScope } from '@/types'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import MailboxRelay from './MailboxRelay'
|
||||
import NewMailboxRelayInput from './NewMailboxRelayInput'
|
||||
import SaveButton from './SaveButton'
|
||||
import { TMailboxRelay, TMailboxRelayScope } from './types'
|
||||
|
||||
export default function MailboxSetting() {
|
||||
const { t } = useTranslation()
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
export type TMailboxRelayScope = 'read' | 'write' | 'both'
|
||||
export type TMailboxRelay = {
|
||||
url: string
|
||||
scope: TMailboxRelayScope
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@ import { useToast } from '@/hooks/use-toast'
|
|||
import { createCommentDraftEvent, createShortTextNoteDraftEvent } from '@/lib/draft-event'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import client from '@/services/client.service'
|
||||
import { ChevronDown, LoaderCircle } from 'lucide-react'
|
||||
import { ChevronDown, ImageUp, LoaderCircle } from 'lucide-react'
|
||||
import { Event, kinds } from 'nostr-tools'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
|
@ -32,6 +32,7 @@ export default function NormalPostContent({
|
|||
const [posting, setPosting] = useState(false)
|
||||
const [showMoreOptions, setShowMoreOptions] = useState(false)
|
||||
const [addClientTag, setAddClientTag] = useState(false)
|
||||
const [uploadingPicture, setUploadingPicture] = useState(false)
|
||||
const canPost = !!content && !posting
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -116,7 +117,13 @@ export default function NormalPostContent({
|
|||
setPictureInfos((prev) => [...prev, { url, tags }])
|
||||
setContent((prev) => `${prev}\n${url}`)
|
||||
}}
|
||||
/>
|
||||
onUploadingChange={setUploadingPicture}
|
||||
accept="image/*,video/*,audio/*"
|
||||
>
|
||||
<Button variant="secondary" disabled={uploadingPicture}>
|
||||
{uploadingPicture ? <LoaderCircle className="animate-spin" /> : <ImageUp />}
|
||||
</Button>
|
||||
</Uploader>
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-foreground gap-0 px-0"
|
||||
|
|
|
|||
|
|
@ -5,8 +5,9 @@ import { Textarea } from '@/components/ui/textarea'
|
|||
import { StorageKey } from '@/constants'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
import { createPictureNoteDraftEvent } from '@/lib/draft-event'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { ChevronDown, LoaderCircle, X } from 'lucide-react'
|
||||
import { ChevronDown, Loader, LoaderCircle, Plus, X } from 'lucide-react'
|
||||
import { Dispatch, SetStateAction, useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
|
@ -177,6 +178,7 @@ function PictureUploader({
|
|||
>
|
||||
}) {
|
||||
const [index, setIndex] = useState(-1)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -203,11 +205,20 @@ function PictureUploader({
|
|||
</div>
|
||||
))}
|
||||
<Uploader
|
||||
variant="big"
|
||||
onUploadSuccess={({ url, tags }) => {
|
||||
setPictureInfos((prev) => [...prev, { url, tags }])
|
||||
}}
|
||||
/>
|
||||
onUploadingChange={setUploading}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col gap-2 items-center justify-center aspect-square w-full rounded-lg border border-dashed',
|
||||
uploading ? 'cursor-not-allowed text-muted-foreground' : 'clickable'
|
||||
)}
|
||||
>
|
||||
{uploading ? <Loader size={36} className="animate-spin" /> : <Plus size={36} />}
|
||||
</div>
|
||||
</Uploader>
|
||||
</div>
|
||||
{index >= 0 &&
|
||||
createPortal(
|
||||
|
|
|
|||
|
|
@ -1,19 +1,21 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { ImageUp, Loader, LoaderCircle, Plus } from 'lucide-react'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useRef } from 'react'
|
||||
import { z } from 'zod'
|
||||
|
||||
export default function Uploader({
|
||||
children,
|
||||
onUploadSuccess,
|
||||
variant = 'button'
|
||||
onUploadingChange,
|
||||
className,
|
||||
accept = 'image/*'
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onUploadSuccess: ({ url, tags }: { url: string; tags: string[][] }) => void
|
||||
variant?: 'button' | 'big'
|
||||
onUploadingChange?: (uploading: boolean) => void
|
||||
className?: string
|
||||
accept?: string
|
||||
}) {
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const { signHttpAuth } = useNostr()
|
||||
const { toast } = useToast()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
|
@ -26,7 +28,7 @@ export default function Uploader({
|
|||
formData.append('file', file)
|
||||
|
||||
try {
|
||||
setUploading(true)
|
||||
onUploadingChange?.(true)
|
||||
const url = 'https://nostr.build/api/v2/nip96/upload'
|
||||
const auth = await signHttpAuth(url, 'POST')
|
||||
const response = await fetch(url, {
|
||||
|
|
@ -60,7 +62,7 @@ export default function Uploader({
|
|||
fileInputRef.current.value = ''
|
||||
}
|
||||
} finally {
|
||||
setUploading(false)
|
||||
onUploadingChange?.(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -71,41 +73,16 @@ export default function Uploader({
|
|||
}
|
||||
}
|
||||
|
||||
if (variant === 'button') {
|
||||
return (
|
||||
<>
|
||||
<Button variant="secondary" onClick={handleUploadClick} disabled={uploading}>
|
||||
{uploading ? <LoaderCircle className="animate-spin" /> : <ImageUp />}
|
||||
</Button>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileChange}
|
||||
accept="image/*,video/*,audio/*"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col gap-2 items-center justify-center aspect-square w-full rounded-lg border border-dashed',
|
||||
uploading ? 'cursor-not-allowed text-muted-foreground' : 'clickable'
|
||||
)}
|
||||
onClick={handleUploadClick}
|
||||
>
|
||||
{uploading ? <Loader size={36} className="animate-spin" /> : <Plus size={36} />}
|
||||
</div>
|
||||
<div onClick={handleUploadClick} className={className}>
|
||||
{children}
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileChange}
|
||||
accept="image/*"
|
||||
accept={accept}
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue