mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-09-19 12:00:32 +02:00
Finish sign up view
This commit is contained in:
5
.changeset/pink-ears-fold.md
Normal file
5
.changeset/pink-ears-fold.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add sign up view
|
19
src/app.tsx
19
src/app.tsx
@@ -134,7 +134,24 @@ const router = createHashRouter([
|
||||
},
|
||||
{
|
||||
path: "signup",
|
||||
element: <SignupView />,
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
element: (
|
||||
<PageProviders>
|
||||
<SignupView />
|
||||
</PageProviders>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: ":step",
|
||||
element: (
|
||||
<PageProviders>
|
||||
<SignupView />
|
||||
</PageProviders>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "streams/:naddr",
|
||||
|
@@ -8,7 +8,8 @@ export function embedCashuTokens(content: EmbedableContent) {
|
||||
return embedJSX(content, {
|
||||
regexp: getMatchCashu(),
|
||||
render: (match) => {
|
||||
return <InlineCachuCard token={match[0]} />;
|
||||
// set zIndex and position so link over dose not cover card
|
||||
return <InlineCachuCard token={match[0]} zIndex={1} position="relative" />;
|
||||
},
|
||||
name: "emoji",
|
||||
});
|
||||
|
@@ -5,6 +5,6 @@ export function embedLightningInvoice(content: EmbedableContent) {
|
||||
return embedJSX(content, {
|
||||
name: "Lightning Invoice",
|
||||
regexp: /(lightning:)?(LNBC[A-Za-z0-9]+)/gim,
|
||||
render: (match) => <InlineInvoiceCard paymentRequest={match[2]} />,
|
||||
render: (match) => <InlineInvoiceCard paymentRequest={match[2]} zIndex={1} position="relative" />,
|
||||
});
|
||||
}
|
||||
|
@@ -1,5 +1,8 @@
|
||||
import { CSSProperties } from "react";
|
||||
import { Box, useColorMode } from "@chakra-ui/react";
|
||||
|
||||
const setZIndex: CSSProperties = { zIndex: 1, position: "relative" };
|
||||
|
||||
// nostr:nevent1qqsve4ud5v8gjds2f2h7exlmjvhqayu4s520pge7frpwe22wezny0pcpp4mhxue69uhkummn9ekx7mqprdmhxue69uhkvet9v3ejumn0wd68ytnzv9hxgtmdv4kk2mxs3z0
|
||||
export function renderWavlakeUrl(match: URL) {
|
||||
if (match.hostname !== "wavlake.com") return null;
|
||||
@@ -13,7 +16,7 @@ export function renderWavlakeUrl(match: URL) {
|
||||
frameBorder="0"
|
||||
title="Wavlake Embed"
|
||||
src={embedUrl.toString()}
|
||||
style={{ width: "100%", aspectRatio: 576 / 356, maxWidth: 573 }}
|
||||
style={{ width: "100%", aspectRatio: 576 / 356, maxWidth: 573, ...setZIndex }}
|
||||
></iframe>
|
||||
);
|
||||
}
|
||||
@@ -34,7 +37,7 @@ export function renderAppleMusicUrl(match: URL) {
|
||||
frameBorder="0"
|
||||
title={isList ? "Apple Music List Embed" : "Apple Music Embed"}
|
||||
height={isList ? 450 : 175}
|
||||
style={{ width: "100%", maxWidth: "660px", overflow: "hidden", background: "transparent" }}
|
||||
style={{ width: "100%", maxWidth: "660px", overflow: "hidden", background: "transparent", ...setZIndex }}
|
||||
src={embedUrl.toString()}
|
||||
></iframe>
|
||||
);
|
||||
@@ -55,7 +58,7 @@ export function renderSpotifyUrl(match: URL) {
|
||||
|
||||
return (
|
||||
<iframe
|
||||
style={{ borderRadius: "12px" }}
|
||||
style={{ borderRadius: "12px", ...setZIndex }}
|
||||
width="100%"
|
||||
height={isList ? 400 : 152}
|
||||
title={isList ? "Spotify List Embed" : "Spotify Embed"}
|
||||
@@ -84,6 +87,7 @@ export function renderTidalUrl(match: URL) {
|
||||
width="100%"
|
||||
height={isList ? 400 : 96}
|
||||
title={isList ? "Tidal List Embed" : "Tidal Embed"}
|
||||
style={setZIndex}
|
||||
></iframe>
|
||||
);
|
||||
}
|
||||
@@ -102,6 +106,7 @@ export function renderSongDotLinkUrl(match: URL) {
|
||||
aspectRatio={16 / 10}
|
||||
src={`https://odesli.co/embed/?url=${encodeURIComponent(match.href)}&theme=${colorMode}`}
|
||||
sandbox="allow-same-origin allow-scripts allow-presentation allow-popups allow-popups-to-escape-sandbox"
|
||||
style={setZIndex}
|
||||
></Box>
|
||||
);
|
||||
}
|
||||
|
@@ -41,6 +41,7 @@ export function renderYoutubeUrl(match: URL) {
|
||||
frameBorder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen"
|
||||
width="100%"
|
||||
style={{ zIndex: 1, position: "relative" }}
|
||||
></iframe>
|
||||
</AspectRatio>
|
||||
);
|
||||
@@ -58,6 +59,7 @@ export function renderYoutubeUrl(match: URL) {
|
||||
frameBorder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen"
|
||||
width="100%"
|
||||
style={{ zIndex: 1, position: "relative" }}
|
||||
></iframe>
|
||||
</AspectRatio>
|
||||
);
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Box, Button, ButtonGroup, Card, Heading, IconButton, Link } from "@chakra-ui/react";
|
||||
import { Box, Button, ButtonGroup, Card, CardProps, Heading, IconButton, Link } from "@chakra-ui/react";
|
||||
import { getDecodedToken, Token } from "@cashu/cashu-ts";
|
||||
|
||||
import { CopyIconButton } from "./copy-icon-button";
|
||||
@@ -22,7 +22,7 @@ function RedeemButton({ token }: { token: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function InlineCachuCard({ token }: { token: string }) {
|
||||
export default function InlineCachuCard({ token, ...props }: Omit<CardProps, "children"> & { token: string }) {
|
||||
const account = useCurrentAccount();
|
||||
|
||||
const [cashu, setCashu] = useState<Token>();
|
||||
@@ -39,7 +39,7 @@ export default function InlineCachuCard({ token }: { token: string }) {
|
||||
|
||||
const amount = cashu?.token[0].proofs.reduce((acc, v) => acc + v.amount, 0);
|
||||
return (
|
||||
<Card p="4" flexDirection="row" borderColor="green.500" alignItems="center" gap="4" flexWrap="wrap">
|
||||
<Card p="4" flexDirection="row" borderColor="green.500" alignItems="center" gap="4" flexWrap="wrap" {...props}>
|
||||
<ECashIcon boxSize={10} color="green.500" />
|
||||
<Box>
|
||||
<Heading size="md">{amount} Cashu sats</Heading>
|
||||
|
@@ -2,7 +2,7 @@ import React, { useState } from "react";
|
||||
import { useAsync } from "react-use";
|
||||
import dayjs from "dayjs";
|
||||
import { requestProvider } from "webln";
|
||||
import { Box, Button, ButtonGroup, IconButton, Text } from "@chakra-ui/react";
|
||||
import { Box, BoxProps, Button, ButtonGroup, IconButton, Text } from "@chakra-ui/react";
|
||||
|
||||
import { parsePaymentRequest, readablizeSats } from "../helpers/bolt11";
|
||||
import { CopyToClipboardIcon } from "./icons";
|
||||
@@ -10,7 +10,7 @@ import { CopyToClipboardIcon } from "./icons";
|
||||
export type InvoiceButtonProps = {
|
||||
paymentRequest: string;
|
||||
};
|
||||
export const InlineInvoiceCard = ({ paymentRequest }: InvoiceButtonProps) => {
|
||||
export const InlineInvoiceCard = ({ paymentRequest, ...props }: Omit<BoxProps, "children"> & InvoiceButtonProps) => {
|
||||
const { value: invoice, error } = useAsync(async () => parsePaymentRequest(paymentRequest));
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -50,6 +50,7 @@ export const InlineInvoiceCard = ({ paymentRequest }: InvoiceButtonProps) => {
|
||||
flexWrap="wrap"
|
||||
gap="4"
|
||||
alignItems="center"
|
||||
{...props}
|
||||
>
|
||||
<Box flexGrow={1}>
|
||||
<Text fontWeight="bold">Lightning Invoice</Text>
|
||||
|
109
src/views/signup/backup-step.tsx
Normal file
109
src/views/signup/backup-step.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
Alert,
|
||||
AlertIcon,
|
||||
Button,
|
||||
Flex,
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
FormLabel,
|
||||
Heading,
|
||||
Input,
|
||||
} from "@chakra-ui/react";
|
||||
import { nip19 } from "nostr-tools";
|
||||
|
||||
import { containerProps } from "./common";
|
||||
import { CopyIconButton } from "../../components/copy-icon-button";
|
||||
import styled from "@emotion/styled";
|
||||
import { useState } from "react";
|
||||
|
||||
const Blockquote = styled.figure`
|
||||
padding: var(--chakra-sizes-2) var(--chakra-sizes-4);
|
||||
border-radius: var(--chakra-radii-xl);
|
||||
background-color: var(--chakra-colors-chakra-subtle-bg);
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: "“";
|
||||
font-size: 10rem;
|
||||
line-height: 1em;
|
||||
top: var(--chakra-sizes-2);
|
||||
left: var(--chakra-sizes-2);
|
||||
position: absolute;
|
||||
opacity: 0.2;
|
||||
}
|
||||
&::after {
|
||||
content: "”";
|
||||
font-size: 10rem;
|
||||
line-height: 1em;
|
||||
bottom: calc(-0.75em + var(--chakra-sizes-2));
|
||||
right: var(--chakra-sizes-2);
|
||||
position: absolute;
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
figcaption,
|
||||
blockquote {
|
||||
margin: var(--chakra-sizes-4);
|
||||
}
|
||||
`;
|
||||
|
||||
export default function BackupStep({ secretKey, onConfirm }: { secretKey: string; onConfirm: () => void }) {
|
||||
const nsec = nip19.nsecEncode(secretKey);
|
||||
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [last4, setLast4] = useState("");
|
||||
|
||||
if (confirmed) {
|
||||
return (
|
||||
<Flex gap="4" {...containerProps}>
|
||||
<Heading>Confirm secret key</Heading>
|
||||
<FormControl mb="4">
|
||||
<FormLabel>Last four letters of secret key</FormLabel>
|
||||
<Input value={last4} onChange={(e) => setLast4(e.target.value)} placeholder="xxxx" autoFocus />
|
||||
<FormHelperText>This is the key to access your account, keep it secret.</FormHelperText>
|
||||
</FormControl>
|
||||
<Button w="full" maxW="sm" colorScheme="primary" onClick={onConfirm} isDisabled={last4 !== nsec.slice(-4)}>
|
||||
Confirm
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() => {
|
||||
setConfirmed(false);
|
||||
setLast4("");
|
||||
}}
|
||||
>
|
||||
Go back... I didn't save it
|
||||
</Button>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex gap="4" {...containerProps} maxW="7in">
|
||||
<Heading>Backup your keys</Heading>
|
||||
|
||||
<Blockquote>
|
||||
<blockquote>“Keep It Secret, Keep it Safe.</blockquote>
|
||||
<figcaption>— Gandalf, The Fellowship of the Ring</figcaption>
|
||||
</Blockquote>
|
||||
|
||||
<Alert status="info">
|
||||
<AlertIcon />
|
||||
Your secret key is like your password, if anyone gets a hold of it they will have complete control over your
|
||||
account
|
||||
</Alert>
|
||||
|
||||
<FormControl mb="4">
|
||||
<FormLabel>Secret Key</FormLabel>
|
||||
<Flex gap="2">
|
||||
<Input value={nsec} />
|
||||
<CopyIconButton aria-label="Copy nsec" title="Copy nsec" text={nsec} />
|
||||
</Flex>
|
||||
<FormHelperText>This is the key to access your account, keep it secret.</FormHelperText>
|
||||
</FormControl>
|
||||
<Button w="full" maxW="sm" colorScheme="primary" onClick={() => setConfirmed(true)}>
|
||||
I have saved my secret key
|
||||
</Button>
|
||||
</Flex>
|
||||
);
|
||||
}
|
11
src/views/signup/common.tsx
Normal file
11
src/views/signup/common.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Avatar, FlexProps } from "@chakra-ui/react";
|
||||
|
||||
export const AppIcon = () => <Avatar src="/apple-touch-icon.png" size="lg" flexShrink={0} />;
|
||||
|
||||
export const containerProps: FlexProps = {
|
||||
w: "full",
|
||||
maxW: "sm",
|
||||
mx: "4",
|
||||
alignItems: "center",
|
||||
direction: "column",
|
||||
};
|
93
src/views/signup/create-step.tsx
Normal file
93
src/views/signup/create-step.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { generatePrivateKey, finishEvent, Kind, getPublicKey } from "nostr-tools";
|
||||
import { Avatar, Box, Button, Flex, Heading, Text, useToast } from "@chakra-ui/react";
|
||||
|
||||
import { Kind0ParsedContent } from "../../helpers/user-metadata";
|
||||
import { containerProps } from "./common";
|
||||
import dayjs from "dayjs";
|
||||
import { nostrBuildUploadImage } from "../../helpers/nostr-build";
|
||||
import NostrPublishAction from "../../classes/nostr-publish-action";
|
||||
import accountService from "../../services/account";
|
||||
import signingService from "../../services/signing";
|
||||
import clientRelaysService from "../../services/client-relays";
|
||||
import { RelayMode } from "../../classes/relay";
|
||||
|
||||
export default function CreateStep({
|
||||
metadata,
|
||||
profileImage,
|
||||
relays,
|
||||
onBack,
|
||||
onSubmit,
|
||||
}: {
|
||||
metadata: Kind0ParsedContent;
|
||||
relays: string[];
|
||||
profileImage?: File;
|
||||
onBack: () => void;
|
||||
onSubmit: (secretKey: string) => void;
|
||||
}) {
|
||||
const toast = useToast();
|
||||
|
||||
const [preview, setPreview] = useState("");
|
||||
useEffect(() => {
|
||||
if (profileImage) {
|
||||
const url = URL.createObjectURL(profileImage);
|
||||
setPreview(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}
|
||||
}, [profileImage]);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const createProfile = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const hex = generatePrivateKey();
|
||||
|
||||
const uploaded = profileImage
|
||||
? await nostrBuildUploadImage(profileImage, async (draft) => finishEvent(draft, hex))
|
||||
: undefined;
|
||||
|
||||
// create profile
|
||||
const kind0 = finishEvent(
|
||||
{
|
||||
content: JSON.stringify({ ...metadata, picture: uploaded?.url }),
|
||||
created_at: dayjs().unix(),
|
||||
kind: Kind.Metadata,
|
||||
tags: [],
|
||||
},
|
||||
hex,
|
||||
);
|
||||
|
||||
new NostrPublishAction("Create Profile", [...relays, "wss://purplepag.es"], kind0);
|
||||
|
||||
// login
|
||||
const pubkey = getPublicKey(hex);
|
||||
const encrypted = await signingService.encryptSecKey(hex);
|
||||
accountService.addAccount({ pubkey, relays, ...encrypted, readonly: false });
|
||||
accountService.switchAccount(pubkey);
|
||||
|
||||
// set relays
|
||||
await clientRelaysService.postUpdatedRelays(relays.map((url) => ({ url, mode: RelayMode.ALL })));
|
||||
|
||||
onSubmit(hex);
|
||||
} catch (e) {
|
||||
if (e instanceof Error) toast({ description: e.message, status: "error" });
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex gap="4" {...containerProps}>
|
||||
<Avatar size="xl" src={preview} />
|
||||
<Flex direction="column" alignItems="center">
|
||||
<Heading size="md">{metadata.display_name}</Heading>
|
||||
{metadata.about && <Text>{metadata.about}</Text>}
|
||||
</Flex>
|
||||
<Button w="full" colorScheme="primary" isLoading={loading} onClick={createProfile} autoFocus>
|
||||
Create profile
|
||||
</Button>
|
||||
<Button w="full" variant="link" onClick={onBack}>
|
||||
Back
|
||||
</Button>
|
||||
</Flex>
|
||||
);
|
||||
}
|
61
src/views/signup/finished-step.tsx
Normal file
61
src/views/signup/finished-step.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Box, Button, Card, Flex, Heading, Text } from "@chakra-ui/react";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import { useAsync } from "react-use";
|
||||
|
||||
import { UserAvatarLink } from "../../components/user-avatar-link";
|
||||
import { UserLink } from "../../components/user-link";
|
||||
import { containerProps } from "./common";
|
||||
import { UserFollowButton } from "../../components/user-follow-button";
|
||||
import { Kind0ParsedContent } from "../../helpers/user-metadata";
|
||||
import { UserDnsIdentityIcon } from "../../components/user-dns-identity-icon";
|
||||
|
||||
type TrendingApi = {
|
||||
profiles: {
|
||||
pubkey: string;
|
||||
relays: string[];
|
||||
profile: {
|
||||
content: string;
|
||||
id: string;
|
||||
kind: 0;
|
||||
created_at: number;
|
||||
};
|
||||
}[];
|
||||
};
|
||||
|
||||
function About({ profile }: { profile: { content: string } }) {
|
||||
const { value: metadata, error } = useAsync(
|
||||
async () => JSON.parse(profile.content) as Kind0ParsedContent,
|
||||
[profile.content],
|
||||
);
|
||||
return metadata ? <Text>{metadata.about}</Text> : null;
|
||||
}
|
||||
|
||||
export default function FinishedStep() {
|
||||
const { value: trending } = useAsync(async () => {
|
||||
return await fetch("https://api.nostr.band/v0/trending/profiles").then((res) => res.json() as Promise<TrendingApi>);
|
||||
});
|
||||
|
||||
return (
|
||||
<Flex gap="4" {...containerProps} maxW="6in">
|
||||
<Heading>Follow a few others</Heading>
|
||||
<Flex overflowX="hidden" overflowY="scroll" minH="4in" maxH="6in" direction="column" gap="2" w="full">
|
||||
{trending?.profiles.map(({ pubkey, profile }) => (
|
||||
<Card p="4" key={pubkey} variant="outline" gap="2">
|
||||
<Flex direction="row" alignItems="center" gap="4">
|
||||
<UserAvatarLink pubkey={pubkey} />
|
||||
<Flex direction="column" overflow="hidden">
|
||||
<UserLink pubkey={pubkey} fontWeight="bold" fontSize="lg" isTruncated />
|
||||
<UserDnsIdentityIcon pubkey={pubkey} />
|
||||
</Flex>
|
||||
<UserFollowButton pubkey={pubkey} flexShrink={0} ml="auto" />
|
||||
</Flex>
|
||||
{profile && <About profile={profile} />}
|
||||
</Card>
|
||||
))}
|
||||
</Flex>
|
||||
<Button as={RouterLink} to="/" colorScheme="primary" maxW="sm" w="full">
|
||||
Start exploring nostr
|
||||
</Button>
|
||||
</Flex>
|
||||
);
|
||||
}
|
@@ -1,216 +1,73 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
Center,
|
||||
Flex,
|
||||
FlexProps,
|
||||
Heading,
|
||||
Input,
|
||||
SimpleGrid,
|
||||
Text,
|
||||
VisuallyHiddenInput,
|
||||
} from "@chakra-ui/react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Link as RouterLink, useLocation } from "react-router-dom";
|
||||
import { useSet } from "react-use";
|
||||
import { useState } from "react";
|
||||
import { Center } from "@chakra-ui/react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import { Kind0ParsedContent } from "../../helpers/user-metadata";
|
||||
import { useRelayInfo } from "../../hooks/use-relay-info";
|
||||
import { RelayFavicon } from "../../components/relay-favicon";
|
||||
import ImagePlus from "../../components/icons/image-plus";
|
||||
|
||||
const containerProps: FlexProps = {
|
||||
w: "full",
|
||||
maxW: "sm",
|
||||
mx: "4",
|
||||
alignItems: "center",
|
||||
direction: "column",
|
||||
};
|
||||
const AppIcon = () => <Avatar src="/apple-touch-icon.png" size="lg" flexShrink={0} />;
|
||||
|
||||
function NameStep({ onSubmit }: { onSubmit: (metadata: Kind0ParsedContent) => void }) {
|
||||
const location = useLocation();
|
||||
const { register, handleSubmit } = useForm({
|
||||
defaultValues: {
|
||||
name: "",
|
||||
},
|
||||
mode: "all",
|
||||
});
|
||||
const submit = handleSubmit((values) => {
|
||||
const displayName = values.name;
|
||||
const username = values.name.toLocaleLowerCase().replaceAll(/(\p{Z}|\p{P}|\p{C}|\p{M})/gu, "_");
|
||||
|
||||
onSubmit({
|
||||
name: username,
|
||||
display_name: displayName,
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<Flex as="form" gap="2" onSubmit={submit} {...containerProps}>
|
||||
<AppIcon />
|
||||
<Heading size="lg" mb="2">
|
||||
Sign up
|
||||
</Heading>
|
||||
<Text>What should we call you?</Text>
|
||||
<Input placeholder="Jane" w="full" mb="2" {...register("name", { required: true })} autoComplete="off" />
|
||||
<Button w="full" colorScheme="primary" mb="4">
|
||||
Next
|
||||
</Button>
|
||||
<Text fontWeight="bold">Already have an account?</Text>
|
||||
<Button as={RouterLink} to="/signin" state={location.state}>
|
||||
Sign in
|
||||
</Button>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileImageStep({ displayName, onSubmit }: { displayName?: string; onSubmit: (picture?: File) => void }) {
|
||||
const [file, setFile] = useState<File>();
|
||||
const uploadRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const [preview, setPreview] = useState("");
|
||||
useEffect(() => {
|
||||
if (file) {
|
||||
const url = URL.createObjectURL(file);
|
||||
setPreview(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}
|
||||
}, [file]);
|
||||
|
||||
return (
|
||||
<Flex gap="4" {...containerProps}>
|
||||
<Heading size="lg" mb="2">
|
||||
Add a profile image
|
||||
</Heading>
|
||||
<VisuallyHiddenInput
|
||||
type="file"
|
||||
accept="image/*"
|
||||
ref={uploadRef}
|
||||
onChange={(e) => setFile(e.target.files?.[0])}
|
||||
/>
|
||||
<Avatar
|
||||
as="button"
|
||||
size="xl"
|
||||
src={preview}
|
||||
onClick={() => uploadRef.current?.click()}
|
||||
cursor="pointer"
|
||||
icon={<ImagePlus boxSize={8} />}
|
||||
/>
|
||||
<Heading size="md">{displayName}</Heading>
|
||||
<Button w="full" colorScheme="primary" mb="4" maxW="sm" onClick={() => onSubmit(file)}>
|
||||
{file ? "Next" : "Skip for now"}
|
||||
</Button>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
function RelayButton({ url, selected, onClick }: { url: string; selected: boolean; onClick: () => void }) {
|
||||
const { info } = useRelayInfo(url);
|
||||
|
||||
return (
|
||||
<Card
|
||||
variant="outline"
|
||||
size="sm"
|
||||
borderColor={selected ? "primary.500" : "gray.500"}
|
||||
borderRadius="lg"
|
||||
cursor="pointer"
|
||||
onClick={onClick}
|
||||
>
|
||||
<CardBody>
|
||||
<Flex gap="2" mb="2">
|
||||
<RelayFavicon relay={url} />
|
||||
<Box>
|
||||
<Heading size="sm">{info?.name}</Heading>
|
||||
<Text fontSize="sm">{url}</Text>
|
||||
</Box>
|
||||
</Flex>
|
||||
<Text>{info?.description}</Text>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const recommendedRelays = [
|
||||
"wss://relay.damus.io",
|
||||
"wss://welcome.nostr.wine",
|
||||
"wss://nos.lol",
|
||||
"wss://purplerelay.com",
|
||||
"wss://nostr.bitcoiner.social",
|
||||
"wss://nostr-pub.wellorder.net",
|
||||
];
|
||||
const defaultRelaySelection = new Set(["wss://relay.damus.io", "wss://nos.lol", "wss://welcome.nostr.wine"]);
|
||||
function RelayStep({ onSubmit }: { onSubmit: (relays: string[]) => void }) {
|
||||
const [relays, relayActions] = useSet<string>(defaultRelaySelection);
|
||||
|
||||
return (
|
||||
<Flex gap="4" {...containerProps} maxW="8in">
|
||||
<Heading size="lg" mb="2">
|
||||
Select some relays
|
||||
</Heading>
|
||||
|
||||
<SimpleGrid columns={[1, 1, 2]} spacing="4">
|
||||
{recommendedRelays.map((url) => (
|
||||
<RelayButton key={url} url={url} selected={relays.has(url)} onClick={() => relayActions.toggle(url)} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
{relays.size === 0 && <Text color="orange">You must select at least one relay</Text>}
|
||||
<Button
|
||||
w="full"
|
||||
colorScheme="primary"
|
||||
mb="4"
|
||||
maxW="sm"
|
||||
isDisabled={relays.size === 0}
|
||||
onClick={() => onSubmit(Array.from(relays))}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
import NameStep from "./name-step";
|
||||
import ProfileImageStep from "./profile-image-step";
|
||||
import RelayStep from "./relay-step";
|
||||
import CreateStep from "./create-step";
|
||||
import BackupStep from "./backup-step";
|
||||
import FinishedStep from "./finished-step";
|
||||
|
||||
export default function SignupView() {
|
||||
const [step, setStep] = useState(0);
|
||||
const step = useParams().step || "name";
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [metadata, setMetadata] = useState<Kind0ParsedContent>({});
|
||||
const [profileImage, setProfileImage] = useState<File>();
|
||||
const [relays, setRelays] = useState<string[]>([]);
|
||||
const [secretKey, setSecretKey] = useState("");
|
||||
|
||||
const renderStep = () => {
|
||||
const next = () => setStep((v) => v + 1);
|
||||
switch (step) {
|
||||
case 0:
|
||||
case "name":
|
||||
return (
|
||||
<NameStep
|
||||
onSubmit={(m) => {
|
||||
setMetadata((v) => ({ ...v, ...m }));
|
||||
next();
|
||||
setMetadata(m);
|
||||
navigate("/signup/profile");
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case 1:
|
||||
case "profile":
|
||||
return (
|
||||
<ProfileImageStep
|
||||
displayName={metadata.display_name}
|
||||
onSubmit={(file) => {
|
||||
setProfileImage(file);
|
||||
next();
|
||||
navigate("/signup/relays");
|
||||
}}
|
||||
onBack={() => navigate("/signup/name")}
|
||||
/>
|
||||
);
|
||||
case 2:
|
||||
case "relays":
|
||||
return (
|
||||
<RelayStep
|
||||
onSubmit={(r) => {
|
||||
setRelays(r);
|
||||
next();
|
||||
navigate("/signup/create");
|
||||
}}
|
||||
onBack={() => navigate("/signup/profile")}
|
||||
/>
|
||||
);
|
||||
case "create":
|
||||
return (
|
||||
<CreateStep
|
||||
metadata={metadata}
|
||||
relays={relays}
|
||||
profileImage={profileImage}
|
||||
onBack={() => navigate("/signup/relays")}
|
||||
onSubmit={(hex) => {
|
||||
setSecretKey(hex);
|
||||
navigate("/signup/backup");
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case "backup":
|
||||
return <BackupStep secretKey={secretKey} onConfirm={() => navigate("/signup/finished")} />;
|
||||
case "finished":
|
||||
return <FinishedStep />;
|
||||
}
|
||||
};
|
||||
|
||||
|
46
src/views/signup/name-step.tsx
Normal file
46
src/views/signup/name-step.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Button, Flex, Heading, Input, Text, Textarea } from "@chakra-ui/react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Link as RouterLink, useLocation } from "react-router-dom";
|
||||
|
||||
import { Kind0ParsedContent } from "../../helpers/user-metadata";
|
||||
import { AppIcon, containerProps } from "./common";
|
||||
|
||||
export default function NameStep({ onSubmit }: { onSubmit: (metadata: Kind0ParsedContent) => void }) {
|
||||
const location = useLocation();
|
||||
const { register, handleSubmit, formState } = useForm({
|
||||
defaultValues: {
|
||||
name: "",
|
||||
about: "",
|
||||
},
|
||||
mode: "all",
|
||||
});
|
||||
const submit = handleSubmit((values) => {
|
||||
const displayName = values.name;
|
||||
const username = values.name.toLocaleLowerCase().replaceAll(/(\p{Z}|\p{P}|\p{C}|\p{M})/gu, "_");
|
||||
|
||||
onSubmit({
|
||||
name: username,
|
||||
display_name: displayName,
|
||||
about: values.about,
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<Flex as="form" gap="2" onSubmit={submit} {...containerProps}>
|
||||
<AppIcon />
|
||||
<Heading size="lg" mb="2">
|
||||
Sign up
|
||||
</Heading>
|
||||
<Text>What should we call you?</Text>
|
||||
<Input placeholder="Jane" {...register("name", { required: true })} autoComplete="off" autoFocus />
|
||||
<Textarea placeholder="Short description about yourself." w="full" mb="2" {...register("about")} />
|
||||
<Button w="full" colorScheme="primary" mb="4" isDisabled={!formState.isValid} type="submit">
|
||||
Next
|
||||
</Button>
|
||||
<Text fontWeight="bold">Already have an account?</Text>
|
||||
<Button as={RouterLink} to="/signin" state={location.state}>
|
||||
Sign in
|
||||
</Button>
|
||||
</Flex>
|
||||
);
|
||||
}
|
56
src/views/signup/profile-image-step.tsx
Normal file
56
src/views/signup/profile-image-step.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Avatar, Button, Flex, Heading, VisuallyHiddenInput } from "@chakra-ui/react";
|
||||
|
||||
import ImagePlus from "../../components/icons/image-plus";
|
||||
import { containerProps } from "./common";
|
||||
|
||||
export default function ProfileImageStep({
|
||||
displayName,
|
||||
onSubmit,
|
||||
onBack,
|
||||
}: {
|
||||
displayName?: string;
|
||||
onSubmit: (picture?: File) => void;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const [file, setFile] = useState<File>();
|
||||
const uploadRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const [preview, setPreview] = useState("");
|
||||
useEffect(() => {
|
||||
if (file) {
|
||||
const url = URL.createObjectURL(file);
|
||||
setPreview(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}
|
||||
}, [file]);
|
||||
|
||||
return (
|
||||
<Flex gap="4" {...containerProps}>
|
||||
<Heading size="lg" mb="2">
|
||||
Add a profile image
|
||||
</Heading>
|
||||
<VisuallyHiddenInput
|
||||
type="file"
|
||||
accept="image/*"
|
||||
ref={uploadRef}
|
||||
onChange={(e) => setFile(e.target.files?.[0])}
|
||||
/>
|
||||
<Avatar
|
||||
as="button"
|
||||
size="xl"
|
||||
src={preview}
|
||||
onClick={() => uploadRef.current?.click()}
|
||||
icon={<ImagePlus boxSize={8} />}
|
||||
autoFocus
|
||||
/>
|
||||
<Heading size="md">{displayName}</Heading>
|
||||
<Button w="full" colorScheme="primary" maxW="sm" onClick={() => onSubmit(file)}>
|
||||
{file ? "Next" : "Skip for now"}
|
||||
</Button>
|
||||
<Button w="full" variant="link" onClick={onBack}>
|
||||
Back
|
||||
</Button>
|
||||
</Flex>
|
||||
);
|
||||
}
|
75
src/views/signup/relay-step.tsx
Normal file
75
src/views/signup/relay-step.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { Box, Button, Card, CardBody, Flex, Heading, SimpleGrid, Text } from "@chakra-ui/react";
|
||||
import { useSet } from "react-use";
|
||||
|
||||
import { useRelayInfo } from "../../hooks/use-relay-info";
|
||||
import { RelayFavicon } from "../../components/relay-favicon";
|
||||
import { containerProps } from "./common";
|
||||
|
||||
function RelayButton({ url, selected, onClick }: { url: string; selected: boolean; onClick: () => void }) {
|
||||
const { info } = useRelayInfo(url);
|
||||
|
||||
return (
|
||||
<Card
|
||||
variant="outline"
|
||||
size="sm"
|
||||
borderColor={selected ? "primary.500" : "gray.500"}
|
||||
borderRadius="lg"
|
||||
cursor="pointer"
|
||||
onClick={onClick}
|
||||
>
|
||||
<CardBody>
|
||||
<Flex gap="2" mb="2">
|
||||
<RelayFavicon relay={url} />
|
||||
<Box>
|
||||
<Heading size="sm">{info?.name}</Heading>
|
||||
<Text fontSize="sm">{url}</Text>
|
||||
</Box>
|
||||
</Flex>
|
||||
<Text>{info?.description}</Text>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const recommendedRelays = [
|
||||
"wss://relay.damus.io",
|
||||
"wss://welcome.nostr.wine",
|
||||
"wss://nos.lol",
|
||||
"wss://purplerelay.com",
|
||||
"wss://nostr.bitcoiner.social",
|
||||
"wss://nostr-pub.wellorder.net",
|
||||
];
|
||||
const defaultRelaySelection = new Set(["wss://relay.damus.io", "wss://nos.lol", "wss://welcome.nostr.wine"]);
|
||||
|
||||
export default function RelayStep({ onSubmit, onBack }: { onSubmit: (relays: string[]) => void; onBack: () => void }) {
|
||||
const [relays, relayActions] = useSet<string>(defaultRelaySelection);
|
||||
|
||||
return (
|
||||
<Flex gap="4" {...containerProps} maxW="8in">
|
||||
<Heading size="lg" mb="2">
|
||||
Select some relays
|
||||
</Heading>
|
||||
|
||||
<SimpleGrid columns={[1, 1, 2]} spacing="4">
|
||||
{recommendedRelays.map((url) => (
|
||||
<RelayButton key={url} url={url} selected={relays.has(url)} onClick={() => relayActions.toggle(url)} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
{relays.size === 0 && <Text color="orange">You must select at least one relay</Text>}
|
||||
<Button
|
||||
w="full"
|
||||
colorScheme="primary"
|
||||
maxW="sm"
|
||||
isDisabled={relays.size === 0}
|
||||
onClick={() => onSubmit(Array.from(relays))}
|
||||
autoFocus
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
<Button w="full" variant="link" onClick={onBack}>
|
||||
Back
|
||||
</Button>
|
||||
</Flex>
|
||||
);
|
||||
}
|
Reference in New Issue
Block a user