Skip to content

Commit 6fa09f8

Browse files
committed
feat: implement address component with clipboard functionality and loading state in form submission
1 parent 02869e8 commit 6fa09f8

File tree

4 files changed

+106
-15
lines changed

4 files changed

+106
-15
lines changed
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"use client";
2+
3+
import { Button } from "@acme/ui/button";
4+
import { Check, Copy, Eye, EyeOff } from "lucide-react";
5+
import { useCopyToClipboard } from "~/lib/hooks/use-clipboard";
6+
import { toast } from "sonner";
7+
import { useState } from "react";
8+
9+
export function Address({ address }: { address: string }) {
10+
const [copiedText, copy, setCopiedText] = useCopyToClipboard();
11+
const [hidden, setHidden] = useState(false);
12+
13+
const handleCopy = (text: string) => () => {
14+
copy(text)
15+
.then(() => {
16+
toast.success("Address copied to clipboard.", {
17+
duration: 1000,
18+
});
19+
setTimeout(() => {
20+
setCopiedText(null);
21+
}, 1000);
22+
})
23+
.catch((error) => {
24+
toast.error("Something went wrong.");
25+
});
26+
};
27+
28+
const handleHide = () => {
29+
setHidden(!hidden);
30+
};
31+
32+
return (
33+
<div className="flex flex-row gap-3 items-center">
34+
<h2 className="text-3xl font-bold text-foreground">
35+
{hidden
36+
? "*".repeat(17)
37+
: `${address.slice(0, 8)}...${address.slice(-6)}`}
38+
</h2>
39+
<div className="flex flex-row items-center">
40+
<Button
41+
variant="ghost"
42+
size="icon"
43+
className="rounded-full"
44+
onClick={handleCopy(address)}
45+
>
46+
{copiedText ? (
47+
<Check className="w-4 h-4 text-emerald-400" />
48+
) : (
49+
<Copy className="w-4 h-4 text-muted-foreground" />
50+
)}
51+
</Button>
52+
<Button
53+
variant="ghost"
54+
size="icon"
55+
className="rounded-full"
56+
onClick={handleHide}
57+
>
58+
{hidden ? (
59+
<EyeOff className="w-4 h-4 text-rose-600" />
60+
) : (
61+
<Eye className="w-4 h-4 text-muted-foreground" />
62+
)}
63+
</Button>
64+
</div>
65+
</div>
66+
);
67+
}

apps/nextjs/src/app/address/[address]/page.tsx

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
} from "@acme/ui/dialog";
1919
import Link from "next/link";
2020
import Image from "next/image";
21+
import { Address } from "./_components/address";
2122

2223
export default async function AddressPage({
2324
params,
@@ -47,19 +48,7 @@ export default async function AddressPage({
4748
</Badge>
4849
</div>
4950
<div className="flex flex-row gap-3 w-full justify-between items-center">
50-
<div className="flex flex-row gap-3 items-center">
51-
<h2 className="text-3xl font-bold text-foreground">
52-
{`${address.slice(0, 8)}...${address.slice(-6)}`}
53-
</h2>
54-
<div className="flex flex-row items-center">
55-
<Button variant="ghost" size="icon" className="rounded-full">
56-
<Copy className="w-4 h-4 text-muted-foreground" />
57-
</Button>
58-
<Button variant="ghost" size="icon" className="rounded-full">
59-
<Eye className="w-4 h-4 text-muted-foreground" />
60-
</Button>
61-
</div>
62-
</div>
51+
<Address address={address} />
6352
<div className="flex flex-row gap-3 items-center">
6453
<Link
6554
href="/"

apps/nextjs/src/app/page.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { useForm } from "react-hook-form";
1818
import { zodResolver } from "@hookform/resolvers/zod";
1919
import { ArrowRight, Loader, Search } from "lucide-react";
2020
import { Button } from "@acme/ui/button";
21+
import { useState } from "react";
2122

2223
const formSchema = z.object({
2324
address: z
@@ -33,14 +34,15 @@ const formSchema = z.object({
3334

3435
export default function HomePage() {
3536
const router = useRouter();
37+
const [isLoading, setIsLoading] = useState(false);
3638

3739
const form = useForm<z.infer<typeof formSchema>>({
3840
resolver: zodResolver(formSchema),
3941
defaultValues: { address: "" },
4042
});
4143

4244
async function onSubmit(values: z.infer<typeof formSchema>) {
43-
await new Promise((resolve) => setTimeout(resolve, 300));
45+
setIsLoading(true);
4446

4547
if (!values.address) {
4648
return form.setError("address", {
@@ -106,7 +108,7 @@ export default function HomePage() {
106108
type="submit"
107109
disabled={form.formState.isSubmitting}
108110
>
109-
{form.formState.isSubmitting ? (
111+
{isLoading ? (
110112
<Loader className="animate-spin" />
111113
) : (
112114
<ArrowRight
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { useCallback, useState } from "react";
2+
3+
type CopiedValue = string | null;
4+
5+
type CopyFn = (text: string) => Promise<boolean>;
6+
7+
export function useCopyToClipboard(): [
8+
CopiedValue,
9+
CopyFn,
10+
(value: CopiedValue) => void,
11+
] {
12+
const [copiedText, setCopiedText] = useState<CopiedValue>(null);
13+
14+
const copy: CopyFn = useCallback(async (text) => {
15+
if (!navigator?.clipboard) {
16+
console.warn("Clipboard not supported");
17+
return false;
18+
}
19+
20+
// Try to save to clipboard then save it in the state if worked
21+
try {
22+
await navigator.clipboard.writeText(text);
23+
setCopiedText(text);
24+
return true;
25+
} catch (error) {
26+
console.warn("Copy failed", error);
27+
setCopiedText(null);
28+
return false;
29+
}
30+
}, []);
31+
32+
return [copiedText, copy, setCopiedText];
33+
}

0 commit comments

Comments
 (0)