This repository has no description
5.7 kB
199 lines
1'use client';
2
3import {
4 Stack,
5 Text,
6 TextInput,
7 Button,
8 Alert,
9 UnstyledButton,
10 Combobox,
11 Loader,
12 Group,
13 Avatar,
14 ScrollArea,
15 PasswordInput,
16 Anchor,
17} from '@mantine/core';
18import { MdLock, MdOutlineAlternateEmail } from 'react-icons/md';
19import { BiRightArrowAlt } from 'react-icons/bi';
20import { useCombobox } from '@mantine/core';
21import { useState } from 'react';
22import { useDebouncedValue } from '@mantine/hooks';
23import { useQuery } from '@tanstack/react-query';
24import { UseFormReturnType } from '@mantine/form';
25import { searchBlueskyUsers } from '@/features/platforms/bluesky/lib/dal';
26
27interface LoginFormValues {
28 handle: string;
29 appPassword: string;
30 useAppPassword: boolean;
31}
32
33type LoginFormType = UseFormReturnType<LoginFormValues>;
34
35interface Props {
36 form: LoginFormType;
37 error: string;
38 isLoading: boolean;
39 onSubmit: (e: React.FormEvent) => void;
40 onSwitchToOAuth: () => void;
41}
42
43export default function AppPasswordLoginForm(props: Props) {
44 const combobox = useCombobox({
45 onDropdownClose: () => combobox.resetSelectedOption(),
46 });
47
48 const [inputValue, setInputValue] = useState(props.form.values.handle);
49 const [debounced] = useDebouncedValue(inputValue, 200);
50
51 const {
52 data: actors = [],
53 isFetching,
54 error,
55 } = useQuery({
56 queryKey: ['bluesky user search', debounced],
57 queryFn: () => searchBlueskyUsers(debounced),
58 enabled: debounced.trim().length > 0,
59 });
60
61 const suggestions = actors;
62 const empty =
63 !error &&
64 !isFetching &&
65 debounced.trim().length > 0 &&
66 suggestions.length === 0;
67
68 const options = suggestions.map((user) => (
69 <Combobox.Option key={user.did} value={user.handle} p={5}>
70 <Group gap={'xs'} wrap="nowrap">
71 <Avatar
72 src={user.avatar?.replace('avatar', 'avatar_thumbnail')}
73 alt={`${user.handle}'s avatar`}
74 />
75 <Stack gap={0}>
76 <Text fw={500} c={'bright'} lineClamp={1}>
77 {user.displayName || user.handle}
78 </Text>
79 <Text fw={500} c={'gray'} lineClamp={1}>
80 @{user.handle}
81 </Text>
82 </Stack>
83 </Group>
84 </Combobox.Option>
85 ));
86
87 return (
88 <Stack gap="xl">
89 <form
90 onSubmit={(e) => {
91 combobox.closeDropdown();
92 props.onSubmit(e);
93 }}
94 >
95 <Stack align="center">
96 <Combobox
97 shadow="sm"
98 radius={'md'}
99 store={combobox}
100 withinPortal={false}
101 onOptionSubmit={(handleValue) => {
102 props.form.setFieldValue('handle', handleValue);
103 setInputValue(handleValue);
104 combobox.closeDropdown();
105 }}
106 >
107 <Combobox.Target>
108 <TextInput
109 autoComplete="username"
110 label="Handle"
111 placeholder="you.bsky.social"
112 key={props.form.key('handle')}
113 value={inputValue}
114 onChange={(e) => {
115 const val = e.currentTarget.value;
116 setInputValue(val);
117 props.form.setFieldValue('handle', val);
118 combobox.openDropdown();
119 }}
120 onFocus={() => combobox.openDropdown()}
121 onBlur={() => combobox.closeDropdown()}
122 leftSection={<MdOutlineAlternateEmail size={22} />}
123 rightSection={isFetching && <Loader size={18} />}
124 variant="filled"
125 size="lg"
126 w="100%"
127 miw={300}
128 required
129 />
130 </Combobox.Target>
131
132 <Combobox.Dropdown
133 hidden={debounced.trim().length === 0 || isFetching}
134 >
135 <Combobox.Options>
136 <ScrollArea.Autosize type="scroll" mah={200}>
137 {error && (
138 <Combobox.Empty>
139 Could not search for profiles
140 </Combobox.Empty>
141 )}
142 {empty && <Combobox.Empty>No profiles found</Combobox.Empty>}
143 {options.length > 0 && options}
144 </ScrollArea.Autosize>
145 </Combobox.Options>
146 </Combobox.Dropdown>
147 </Combobox>
148
149 <PasswordInput
150 autoComplete="password"
151 name="password"
152 label="App password"
153 placeholder="Your password"
154 key={props.form.key('appPassword')}
155 {...props.form.getInputProps('appPassword')}
156 leftSection={<MdLock size={22} />}
157 variant="filled"
158 size="lg"
159 w={'100%'}
160 required
161 />
162
163 <Button
164 type="submit"
165 size="lg"
166 color="var(--mantine-color-dark-filled)"
167 fullWidth
168 rightSection={<BiRightArrowAlt size={22} />}
169 loading={props.isLoading}
170 >
171 Log in
172 </Button>
173
174 {props.error && <Alert title={props.error} color="red" />}
175
176 <Stack gap={0} align="center">
177 <UnstyledButton
178 c={'gray'}
179 fw={600}
180 fz={'sm'}
181 onClick={props.onSwitchToOAuth}
182 >
183 Or{' '}
184 <Text c={'blue'} fw={500} fz={'sm'} span>
185 log in with OAuth
186 </Text>
187 </UnstyledButton>
188 <Text fw={500} fz={'sm'} c={'gray'}>
189 {"Don't have an account? "}
190 <Anchor href="/signup" fw={500} fz={'sm'} c={'blue'}>
191 Sign up
192 </Anchor>
193 </Text>
194 </Stack>
195 </Stack>
196 </form>
197 </Stack>
198 );
199}