@cosxai/ui 1.0.0-alpha.1 → 1.0.0-alpha.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +89 -0
- package/package.json +10 -4
- package/src/brand.css +1 -1
- package/src/components/ActionBar.tsx +607 -0
- package/src/components/ActivityTimeline.tsx +77 -0
- package/src/components/AsyncSelect.tsx +205 -0
- package/src/components/AuthLayout.tsx +127 -0
- package/src/components/BrandLoader.tsx +122 -0
- package/src/components/Breadcrumb.tsx +129 -0
- package/src/components/Button.tsx +48 -40
- package/src/components/ChoiceCards.tsx +68 -0
- package/src/components/CodeInput.tsx +150 -0
- package/src/components/CommandPalette.tsx +228 -0
- package/src/components/ConfirmDialog.tsx +294 -0
- package/src/components/CopyField.tsx +104 -0
- package/src/components/DateInput.tsx +366 -0
- package/src/components/Dialog.tsx +27 -3
- package/src/components/Dropzone.tsx +187 -0
- package/src/components/FileCard.tsx +233 -0
- package/src/components/FileRow.tsx +159 -0
- package/src/components/FolderTree.tsx +377 -0
- package/src/components/ListToolbar.tsx +177 -0
- package/src/components/MentionInput.tsx +233 -0
- package/src/components/Menu.tsx +109 -0
- package/src/components/PageHeader.tsx +62 -0
- package/src/components/PageState.tsx +84 -0
- package/src/components/Progress.tsx +97 -0
- package/src/components/PulseDot.tsx +31 -0
- package/src/components/RecipientsInput.tsx +252 -0
- package/src/components/SearchField.tsx +137 -0
- package/src/components/SegmentedControl.tsx +95 -0
- package/src/components/SidePanel.tsx +130 -0
- package/src/components/Skeleton.tsx +73 -0
- package/src/components/StageProgress.tsx +51 -0
- package/src/components/Steps.tsx +58 -0
- package/src/components/SyncStatus.tsx +56 -0
- package/src/components/ThemeSwitch.tsx +99 -0
- package/src/components/Toast.tsx +45 -8
- package/src/components/UploadCheck.tsx +118 -0
- package/src/components/UploadList.tsx +176 -0
- package/src/components/VirtualList.tsx +90 -0
- package/src/components/WindowDrop.tsx +131 -0
- package/src/components/WorkspaceRow.tsx +80 -0
- package/src/components/inputs-aria.ts +5 -0
- package/src/components/inputs-date.ts +92 -0
- package/src/components/inputs-listbox.tsx +154 -0
- package/src/components/inputs-search.ts +60 -0
- package/src/components/status-files.ts +124 -0
- package/src/components/status-motion.ts +30 -0
- package/src/components/useSelection.ts +142 -0
- package/src/index.ts +45 -1
- package/src/theme-base.css +123 -0
- package/src/theme-reset.css +14 -0
- package/src/theme.css +2 -125
- package/src/components/core.test.tsx +0 -242
- package/src/components/forms.test.tsx +0 -280
- package/src/components/overlays.test.tsx +0 -245
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { KeyRound } from 'lucide-react';
|
|
2
|
+
import { useEffect, useId, useRef, useState, type ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
import { Button } from './Button';
|
|
5
|
+
import { Dialog, DialogClose, DialogContent } from './Dialog';
|
|
6
|
+
import { Icon } from './Icon';
|
|
7
|
+
import { Input } from './Input';
|
|
8
|
+
import { useButtonAction } from './useButtonAction';
|
|
9
|
+
|
|
10
|
+
function errorText(e: unknown): string {
|
|
11
|
+
if (e instanceof Error) return e.message;
|
|
12
|
+
return typeof e === 'string' ? e : 'Something went wrong. Try again.';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type Base = {
|
|
16
|
+
open: boolean;
|
|
17
|
+
onOpenChange: (open: boolean) => void;
|
|
18
|
+
/** A question or an action: "Revoke this share and 4 forwards?" */
|
|
19
|
+
title: ReactNode;
|
|
20
|
+
/** The consequence, in plain words: "Anna and the 4 people she forwarded it to lose access now." */
|
|
21
|
+
description?: ReactNode;
|
|
22
|
+
/** @default "Cancel" */
|
|
23
|
+
cancelLabel?: string | undefined;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type ConfirmDialogProps = Base & {
|
|
27
|
+
/** The primary repeats the verb: "Revoke share", never "OK". */
|
|
28
|
+
confirmLabel: string;
|
|
29
|
+
/** Destructive: the primary is the red button. */
|
|
30
|
+
destructive?: boolean | undefined;
|
|
31
|
+
/** Type-to-confirm: the primary stays off until this word is typed (for what can't be undone at scale). */
|
|
32
|
+
confirmWord?: string | undefined;
|
|
33
|
+
/** Label of the type-to-confirm input. @default `Type ${confirmWord} to confirm` */
|
|
34
|
+
confirmWordLabel?: ReactNode;
|
|
35
|
+
/** Runs on confirm. While it runs the primary is busy; on success the dialog closes; a failure is shown in place. */
|
|
36
|
+
onConfirm: () => Promise<unknown> | unknown;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* ConfirmDialog — a destructive or weighty action, confirmed before it
|
|
41
|
+
* happens (never undone after the fact). The primary names the action;
|
|
42
|
+
* with confirmWord the person types it first. Failures stay in the dialog.
|
|
43
|
+
*/
|
|
44
|
+
export function ConfirmDialog({
|
|
45
|
+
open,
|
|
46
|
+
onOpenChange,
|
|
47
|
+
title,
|
|
48
|
+
description,
|
|
49
|
+
cancelLabel = 'Cancel',
|
|
50
|
+
confirmLabel,
|
|
51
|
+
destructive = false,
|
|
52
|
+
confirmWord,
|
|
53
|
+
confirmWordLabel,
|
|
54
|
+
onConfirm,
|
|
55
|
+
}: ConfirmDialogProps) {
|
|
56
|
+
const [typed, setTyped] = useState('');
|
|
57
|
+
const action = useButtonAction(async () => {
|
|
58
|
+
await onConfirm();
|
|
59
|
+
onOpenChange(false);
|
|
60
|
+
});
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (!open) setTyped('');
|
|
63
|
+
}, [open]);
|
|
64
|
+
const armed = !confirmWord || typed.trim().toLowerCase() === confirmWord.toLowerCase();
|
|
65
|
+
return (
|
|
66
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
67
|
+
<DialogContent
|
|
68
|
+
size="sm"
|
|
69
|
+
title={title}
|
|
70
|
+
description={description}
|
|
71
|
+
footer={
|
|
72
|
+
<>
|
|
73
|
+
<DialogClose asChild>
|
|
74
|
+
<Button variant="secondary">{cancelLabel}</Button>
|
|
75
|
+
</DialogClose>
|
|
76
|
+
<Button
|
|
77
|
+
variant={destructive ? 'danger' : 'primary'}
|
|
78
|
+
disabled={!armed}
|
|
79
|
+
state={action.state === 'done' ? 'busy' : action.state}
|
|
80
|
+
onClick={() => void action.run()}
|
|
81
|
+
>
|
|
82
|
+
{confirmLabel}
|
|
83
|
+
</Button>
|
|
84
|
+
</>
|
|
85
|
+
}
|
|
86
|
+
>
|
|
87
|
+
{confirmWord && (
|
|
88
|
+
<Input
|
|
89
|
+
label={confirmWordLabel ?? `Type ${confirmWord} to confirm`}
|
|
90
|
+
value={typed}
|
|
91
|
+
onChange={(e) => setTyped(e.target.value)}
|
|
92
|
+
autoComplete="off"
|
|
93
|
+
spellCheck={false}
|
|
94
|
+
autoFocus
|
|
95
|
+
/>
|
|
96
|
+
)}
|
|
97
|
+
{action.error !== null && (
|
|
98
|
+
<p role="alert" className="mt-3 mb-0 text-small text-error-text">
|
|
99
|
+
{errorText(action.error)}
|
|
100
|
+
</p>
|
|
101
|
+
)}
|
|
102
|
+
</DialogContent>
|
|
103
|
+
</Dialog>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export type PromptDialogProps = Base & {
|
|
108
|
+
/** The field's label: "Folder name". */
|
|
109
|
+
label: ReactNode;
|
|
110
|
+
defaultValue?: string | undefined;
|
|
111
|
+
/** "Rename", "Create folder". */
|
|
112
|
+
confirmLabel: string;
|
|
113
|
+
/** Return the problem in words, or null when fine. Checked on submit and then as they type. */
|
|
114
|
+
validate?: ((value: string) => string | null) | undefined;
|
|
115
|
+
/** Receives the trimmed value. Busy while it runs; closes on success; a failure shows under the field. */
|
|
116
|
+
onSubmit: (value: string) => Promise<unknown> | unknown;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/** PromptDialog — one value asked for (rename, new folder). The current
|
|
120
|
+
* value is selected on open so typing replaces it; Enter submits. */
|
|
121
|
+
export function PromptDialog({ open, onOpenChange, title, description, cancelLabel = 'Cancel', label, defaultValue = '', confirmLabel, validate, onSubmit }: PromptDialogProps) {
|
|
122
|
+
const [value, setValue] = useState(defaultValue);
|
|
123
|
+
const [problem, setProblem] = useState<string | null>(null);
|
|
124
|
+
const [touched, setTouched] = useState(false);
|
|
125
|
+
const input = useRef<HTMLInputElement | null>(null);
|
|
126
|
+
const formId = useId();
|
|
127
|
+
const action = useButtonAction(async (v: string) => {
|
|
128
|
+
await onSubmit(v);
|
|
129
|
+
onOpenChange(false);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
if (open) {
|
|
134
|
+
setValue(defaultValue);
|
|
135
|
+
setProblem(null);
|
|
136
|
+
setTouched(false);
|
|
137
|
+
}
|
|
138
|
+
}, [open, defaultValue]);
|
|
139
|
+
|
|
140
|
+
const check = (v: string) => validate?.(v.trim()) ?? (v.trim() ? null : 'Enter a name.');
|
|
141
|
+
const shownError = problem ?? (action.error !== null ? errorText(action.error) : null);
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
145
|
+
<DialogContent
|
|
146
|
+
size="sm"
|
|
147
|
+
title={title}
|
|
148
|
+
description={description}
|
|
149
|
+
onOpenAutoFocus={(e) => {
|
|
150
|
+
e.preventDefault();
|
|
151
|
+
input.current?.focus();
|
|
152
|
+
input.current?.select();
|
|
153
|
+
}}
|
|
154
|
+
footer={
|
|
155
|
+
<>
|
|
156
|
+
<DialogClose asChild>
|
|
157
|
+
<Button variant="secondary">{cancelLabel}</Button>
|
|
158
|
+
</DialogClose>
|
|
159
|
+
<Button type="submit" form={formId} state={action.state === 'done' ? 'busy' : action.state}>
|
|
160
|
+
{confirmLabel}
|
|
161
|
+
</Button>
|
|
162
|
+
</>
|
|
163
|
+
}
|
|
164
|
+
>
|
|
165
|
+
<form
|
|
166
|
+
id={formId}
|
|
167
|
+
noValidate
|
|
168
|
+
onSubmit={(e) => {
|
|
169
|
+
e.preventDefault();
|
|
170
|
+
setTouched(true);
|
|
171
|
+
const p = check(value);
|
|
172
|
+
setProblem(p);
|
|
173
|
+
if (!p) void action.run(value.trim());
|
|
174
|
+
}}
|
|
175
|
+
>
|
|
176
|
+
<Input
|
|
177
|
+
ref={input}
|
|
178
|
+
label={label}
|
|
179
|
+
value={value}
|
|
180
|
+
error={shownError ?? undefined}
|
|
181
|
+
onChange={(e) => {
|
|
182
|
+
setValue(e.target.value);
|
|
183
|
+
if (touched) setProblem(check(e.target.value));
|
|
184
|
+
}}
|
|
185
|
+
/>
|
|
186
|
+
</form>
|
|
187
|
+
</DialogContent>
|
|
188
|
+
</Dialog>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export type StepUpDialogProps = Omit<Base, 'title'> & {
|
|
193
|
+
/** @default "Confirm it's you" */
|
|
194
|
+
title?: ReactNode;
|
|
195
|
+
/** @default "Use passkey" */
|
|
196
|
+
passkeyLabel?: string | undefined;
|
|
197
|
+
/** @default "Use authenticator code instead" */
|
|
198
|
+
codeLabel?: string | undefined;
|
|
199
|
+
/** @default "Authenticator code" */
|
|
200
|
+
codeFieldLabel?: ReactNode;
|
|
201
|
+
/** @default "Confirm" */
|
|
202
|
+
confirmLabel?: string | undefined;
|
|
203
|
+
/** Starts the passkey ceremony; resolves when verified (the dialog closes), throws to show why not. */
|
|
204
|
+
onPasskey: () => Promise<unknown>;
|
|
205
|
+
/** Checks a 6-digit authenticator code; resolves when verified, throws to show why not. Omit when there is no authenticator. */
|
|
206
|
+
onCode?: ((code: string) => Promise<unknown>) | undefined;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
/** StepUpDialog — a fresh check before a sensitive action (deleting a
|
|
210
|
+
* workspace): passkey first, an authenticator code as the alternative.
|
|
211
|
+
* UI only — the ceremony is the caller's. */
|
|
212
|
+
export function StepUpDialog({
|
|
213
|
+
open,
|
|
214
|
+
onOpenChange,
|
|
215
|
+
title = "Confirm it's you",
|
|
216
|
+
description,
|
|
217
|
+
cancelLabel = 'Cancel',
|
|
218
|
+
passkeyLabel = 'Use passkey',
|
|
219
|
+
codeLabel = 'Use authenticator code instead',
|
|
220
|
+
codeFieldLabel = 'Authenticator code',
|
|
221
|
+
confirmLabel = 'Confirm',
|
|
222
|
+
onPasskey,
|
|
223
|
+
onCode,
|
|
224
|
+
}: StepUpDialogProps) {
|
|
225
|
+
const [useCode, setUseCode] = useState(false);
|
|
226
|
+
const [code, setCode] = useState('');
|
|
227
|
+
const passkey = useButtonAction(async () => {
|
|
228
|
+
await onPasskey();
|
|
229
|
+
onOpenChange(false);
|
|
230
|
+
});
|
|
231
|
+
const byCode = useButtonAction(async (c: string) => {
|
|
232
|
+
await onCode?.(c);
|
|
233
|
+
onOpenChange(false);
|
|
234
|
+
});
|
|
235
|
+
useEffect(() => {
|
|
236
|
+
if (!open) {
|
|
237
|
+
setUseCode(false);
|
|
238
|
+
setCode('');
|
|
239
|
+
}
|
|
240
|
+
}, [open]);
|
|
241
|
+
const err = useCode ? byCode.error : passkey.error;
|
|
242
|
+
return (
|
|
243
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
244
|
+
<DialogContent
|
|
245
|
+
size="sm"
|
|
246
|
+
title={title}
|
|
247
|
+
description={description}
|
|
248
|
+
footer={
|
|
249
|
+
<>
|
|
250
|
+
<DialogClose asChild>
|
|
251
|
+
<Button variant="secondary">{cancelLabel}</Button>
|
|
252
|
+
</DialogClose>
|
|
253
|
+
{useCode ? (
|
|
254
|
+
<Button disabled={!/^\d{6}$/.test(code)} state={byCode.state === 'done' ? 'busy' : byCode.state} onClick={() => void byCode.run(code)}>
|
|
255
|
+
{confirmLabel}
|
|
256
|
+
</Button>
|
|
257
|
+
) : (
|
|
258
|
+
<Button iconLeft={<Icon icon={KeyRound} />} state={passkey.state === 'done' ? 'busy' : passkey.state} onClick={() => void passkey.run()}>
|
|
259
|
+
{passkeyLabel}
|
|
260
|
+
</Button>
|
|
261
|
+
)}
|
|
262
|
+
</>
|
|
263
|
+
}
|
|
264
|
+
>
|
|
265
|
+
{useCode ? (
|
|
266
|
+
<Input
|
|
267
|
+
label={codeFieldLabel}
|
|
268
|
+
value={code}
|
|
269
|
+
inputMode="numeric"
|
|
270
|
+
autoComplete="one-time-code"
|
|
271
|
+
maxLength={6}
|
|
272
|
+
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
|
273
|
+
autoFocus
|
|
274
|
+
/>
|
|
275
|
+
) : (
|
|
276
|
+
onCode && (
|
|
277
|
+
<button
|
|
278
|
+
type="button"
|
|
279
|
+
onClick={() => setUseCode(true)}
|
|
280
|
+
className="cursor-pointer border-0 bg-transparent p-0 font-sans text-small text-fg underline underline-offset-3 outline-none focus-visible:shadow-(--focus-ring)"
|
|
281
|
+
>
|
|
282
|
+
{codeLabel}
|
|
283
|
+
</button>
|
|
284
|
+
)
|
|
285
|
+
)}
|
|
286
|
+
{err !== null && (
|
|
287
|
+
<p role="alert" className="mt-3 mb-0 text-small text-error-text">
|
|
288
|
+
{errorText(err)}
|
|
289
|
+
</p>
|
|
290
|
+
)}
|
|
291
|
+
</DialogContent>
|
|
292
|
+
</Dialog>
|
|
293
|
+
);
|
|
294
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { Check, Copy } from 'lucide-react';
|
|
2
|
+
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
import { cn } from '../lib/cn';
|
|
5
|
+
import { Field, useField } from './Field';
|
|
6
|
+
import { controlFrame } from './forms-control';
|
|
7
|
+
|
|
8
|
+
export type CopyFieldProps = {
|
|
9
|
+
/** The text shown and copied: a share link, a token. */
|
|
10
|
+
value: string;
|
|
11
|
+
label?: ReactNode;
|
|
12
|
+
hint?: ReactNode;
|
|
13
|
+
/** @default "Copy" */
|
|
14
|
+
copyLabel?: string | undefined;
|
|
15
|
+
/** @default "Copied" */
|
|
16
|
+
copiedLabel?: string | undefined;
|
|
17
|
+
/** Shown when the clipboard refuses and the text is selected instead. @default "Press ⌘C to copy" (Ctrl+C off the Mac) */
|
|
18
|
+
manualHint?: ReactNode;
|
|
19
|
+
/** ms the confirmation stays. @default 1600 */
|
|
20
|
+
copiedFor?: number | undefined;
|
|
21
|
+
onCopied?: ((value: string) => void) | undefined;
|
|
22
|
+
size?: 'sm' | 'md' | 'lg' | undefined;
|
|
23
|
+
id?: string | undefined;
|
|
24
|
+
className?: string | undefined;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const isMac = () => typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* CopyField — a read-only value with a copy button. The confirmation stays
|
|
31
|
+
* in place; if copying fails the text is selected and the hint says how to
|
|
32
|
+
* copy it by hand.
|
|
33
|
+
*/
|
|
34
|
+
export function CopyField({ label, hint, ...rest }: CopyFieldProps) {
|
|
35
|
+
const [manual, setManual] = useState(false);
|
|
36
|
+
const shown = manual ? (rest.manualHint ?? (isMac() ? 'Press ⌘C to copy' : 'Press Ctrl+C to copy')) : hint;
|
|
37
|
+
if (label !== undefined || shown !== undefined) {
|
|
38
|
+
return (
|
|
39
|
+
<Field label={label} hint={shown} id={rest.id}>
|
|
40
|
+
<Control {...rest} onManual={setManual} />
|
|
41
|
+
</Field>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
return <Control {...rest} onManual={setManual} />;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function Control({
|
|
48
|
+
value,
|
|
49
|
+
copyLabel = 'Copy',
|
|
50
|
+
copiedLabel = 'Copied',
|
|
51
|
+
copiedFor = 1600,
|
|
52
|
+
onCopied,
|
|
53
|
+
size,
|
|
54
|
+
id,
|
|
55
|
+
className,
|
|
56
|
+
onManual,
|
|
57
|
+
}: Omit<CopyFieldProps, 'label' | 'hint' | 'manualHint'> & { onManual: (on: boolean) => void }) {
|
|
58
|
+
const field = useField();
|
|
59
|
+
const [copied, setCopied] = useState(false);
|
|
60
|
+
const input = useRef<HTMLInputElement | null>(null);
|
|
61
|
+
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
62
|
+
useEffect(() => () => void (timer.current && clearTimeout(timer.current)), []);
|
|
63
|
+
|
|
64
|
+
const selectAll = () => {
|
|
65
|
+
input.current?.focus();
|
|
66
|
+
input.current?.select();
|
|
67
|
+
};
|
|
68
|
+
const copy = async () => {
|
|
69
|
+
try {
|
|
70
|
+
if (!navigator.clipboard?.writeText) throw new Error('no clipboard');
|
|
71
|
+
await navigator.clipboard.writeText(value);
|
|
72
|
+
onManual(false);
|
|
73
|
+
setCopied(true);
|
|
74
|
+
onCopied?.(value);
|
|
75
|
+
if (timer.current) clearTimeout(timer.current);
|
|
76
|
+
timer.current = setTimeout(() => setCopied(false), copiedFor);
|
|
77
|
+
} catch {
|
|
78
|
+
selectAll();
|
|
79
|
+
onManual(true);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<div className={cn(controlFrame({ size, readOnly: true }), 'pr-1', className)}>
|
|
85
|
+
<input
|
|
86
|
+
ref={input}
|
|
87
|
+
id={id ?? field?.id}
|
|
88
|
+
readOnly
|
|
89
|
+
value={value}
|
|
90
|
+
onFocus={(e) => e.currentTarget.select()}
|
|
91
|
+
aria-describedby={field?.describedBy}
|
|
92
|
+
className="min-w-0 flex-1 truncate border-none bg-transparent p-0 font-sans text-inherit outline-none"
|
|
93
|
+
/>
|
|
94
|
+
<button
|
|
95
|
+
type="button"
|
|
96
|
+
onClick={() => void copy()}
|
|
97
|
+
className="inline-flex h-[calc(100%-8px)] shrink-0 cursor-pointer items-center gap-1.5 rounded-sm bg-page px-2.5 text-meta font-semibold text-fg outline-none hover:bg-well focus-visible:shadow-(--focus-ring)"
|
|
98
|
+
>
|
|
99
|
+
{copied ? <Check size={13} strokeWidth={2} aria-hidden /> : <Copy size={13} aria-hidden />}
|
|
100
|
+
<span aria-live="polite">{copied ? copiedLabel : copyLabel}</span>
|
|
101
|
+
</button>
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
}
|