Skip to content

Commit 6ced31d

Browse files
committed
support batch translation
1 parent f902201 commit 6ced31d

6 files changed

Lines changed: 213 additions & 97 deletions

File tree

Lines changed: 75 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,23 @@
1+
import { Copy, Loader2, MapIcon } from "lucide-react";
2+
import nzh from "nzh/hk";
13
import { useDeferredValue, useEffect, useRef, useState } from "react";
24
import { toast } from "sonner";
35
import useSWR from "swr";
46
import { Textarea } from "~/components/ui/textarea";
57
import { cn } from "~/utils/cn.ts";
68
import type { AddressToEnglishJson } from "~/utils/get-address-data-json";
7-
import { translateAddressToEnglish } from "~/utils/translate-address-to-english";
9+
import {
10+
type TranslateAddressToEnglish,
11+
translateAddressToEnglish,
12+
} from "~/utils/translate-address-to-english";
13+
import { Button } from "./ui/button";
14+
import { ButtonGroup } from "./ui/button-group";
815

916
export const AddressToEnglishInput = () => {
1017
const inputRef = useRef<HTMLTextAreaElement>(null);
1118

1219
const [value, setValue] = useState("");
13-
const [result, setResult] = useState<string>();
14-
const [isValid, setIsValid] = useState(true);
20+
const [result, setResult] = useState<TranslateAddressToEnglish[]>([]);
1521

1622
const deferredValue = useDeferredValue(value.trim());
1723

@@ -30,40 +36,36 @@ export const AddressToEnglishInput = () => {
3036

3137
const address = searchParams.get("address");
3238

33-
if (address) setValue(address);
39+
if (address) setValue(address.replaceAll(";", "\n"));
3440
} catch {
3541
// ignore
3642
}
3743
}, []);
3844

3945
useEffect(() => {
40-
async function translate() {
41-
// update url address param without reloading the page
42-
const url = new URL(location.href);
46+
// update url address param without reloading the page
47+
const url = new URL(location.href);
4348

44-
const searchParams = new URLSearchParams();
49+
const searchParams = new URLSearchParams();
4550

46-
if (deferredValue) searchParams.set("address", deferredValue);
51+
const addresses = deferredValue
52+
.split("\n")
53+
.map((x) => x.trim())
54+
.filter(Boolean);
4755

48-
url.hash = searchParams.toString();
56+
if (deferredValue) searchParams.set("address", addresses.join(";"));
4957

50-
history.replaceState({}, "", url.toString());
58+
url.hash = searchParams.toString();
5159

52-
if (!addressData || !deferredValue) return;
60+
history.replaceState({}, "", url.toString());
5361

54-
const nzh = await import("nzh").then((module) => module.default.hk);
62+
if (!addressData || !deferredValue) return;
5563

56-
const { result, isValid } = translateAddressToEnglish(
57-
addressData,
58-
deferredValue,
59-
nzh,
60-
);
61-
62-
setResult(result);
63-
setIsValid(isValid);
64-
}
65-
66-
void translate();
64+
setResult(
65+
addresses.map((address) =>
66+
translateAddressToEnglish(addressData, address, nzh),
67+
),
68+
);
6769
}, [deferredValue, addressData]);
6870

6971
return (
@@ -75,12 +77,10 @@ export const AddressToEnglishInput = () => {
7577
crossOrigin="anonymous"
7678
/>
7779
<Textarea
80+
name="address"
7881
autoFocus
7982
ref={inputRef}
80-
className={cn(
81-
"whitespace-pre-wrap inline-block text-start break-words break-all text-2xl bg-secondary/15 resize-none focus-visible:ring-0 !ring-offset-0 transition-colors",
82-
!isValid && "border-red-500",
83-
)}
83+
className="whitespace-pre-wrap inline-block text-start break-words break-all text-2xl bg-secondary/15 resize-none focus-visible:ring-0 !ring-offset-0 transition-colors"
8484
value={value}
8585
placeholder="臺北市中正區重慶南路1段122號"
8686
onChange={(event) => {
@@ -91,18 +91,37 @@ export const AddressToEnglishInput = () => {
9191
}}
9292
rows={3}
9393
/>
94-
<Textarea
95-
className="whitespace-pre-wrap inline-block text-start break-words break-all text-lg border-none bg-secondary/50 resize-none focus-visible:ring-0 !ring-offset-0"
96-
value={isLoading ? "正在更新中華郵政資料..." : result}
97-
readOnly
98-
placeholder="No. 122, Sec. 1, Chongqing S. Rd., Zhongzheng Dist., Taipei City 100, Taiwan (R.O.C.)"
99-
rows={3}
100-
/>
101-
{result && (
102-
<div className="flex items-center gap-2 justify-center">
103-
<a
104-
className="text-blue-500 underline"
105-
href={location.href}
94+
{isLoading && (
95+
<span className="text-muted-foreground flex justify-center items-center gap-2">
96+
正在載入中華郵政資料 <Loader2 className="w-4 animate-spin" />
97+
</span>
98+
)}
99+
<div className="flex flex-col gap-2">
100+
{result.map((result, index) => (
101+
<TranslatedRow key={`${index}-${result}`}>{result}</TranslatedRow>
102+
))}
103+
</div>
104+
</div>
105+
);
106+
};
107+
108+
function TranslatedRow({ children }: { children: TranslateAddressToEnglish }) {
109+
return (
110+
<div
111+
className={cn(
112+
"px-3 py-2 rounded-md whitespace-pre-wrap text-start break-words break-all text-lg border-none bg-secondary/50",
113+
!children.isValid && "border-destructive",
114+
)}
115+
>
116+
<span className="text-sm text-muted-foreground pb-1 block">
117+
{children.original}
118+
</span>
119+
<div>
120+
<span className="text-pretty">{children.result}</span>
121+
<ButtonGroup className="float-end inline-block py-1">
122+
<Button
123+
variant="outline"
124+
aria-label="複製到剪貼簿"
106125
onClick={async (e) => {
107126
e.preventDefault();
108127
await navigator.clipboard
@@ -112,18 +131,21 @@ export const AddressToEnglishInput = () => {
112131
toast.success("已複製連結到剪貼簿");
113132
}}
114133
>
115-
複製連結
116-
</a>
117-
<a
118-
href={`https://www.google.com/maps/search/${encodeURIComponent(result)}`}
119-
target="_blank"
120-
rel="noopener noreferrer"
121-
className="text-blue-500 underline"
122-
>
123-
在 Google Maps 上查看
124-
</a>
125-
</div>
126-
)}
134+
<Copy />
135+
</Button>
136+
<Button asChild variant="outline">
137+
<a
138+
target="_blank"
139+
rel="noopener noreferrer"
140+
href={`https://www.google.com/maps/search/${encodeURIComponent(children.result)}`}
141+
title="在 Google Map 查看"
142+
aria-label="在 Google Map 查看"
143+
>
144+
<MapIcon />
145+
</a>
146+
</Button>
147+
</ButtonGroup>
148+
</div>
127149
</div>
128150
);
129-
};
151+
}

src/components/ui/button-group.tsx

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { Slot } from "@radix-ui/react-slot";
2+
import { cva, type VariantProps } from "class-variance-authority";
3+
import { Separator } from "~/components/ui/separator";
4+
import { cn } from "~/utils/cn";
5+
6+
const buttonGroupVariants = cva(
7+
"flex w-fit items-stretch has-[>[data-slot=button-group]]:gap-2 [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
8+
{
9+
variants: {
10+
orientation: {
11+
horizontal:
12+
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
13+
vertical:
14+
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
15+
},
16+
},
17+
defaultVariants: {
18+
orientation: "horizontal",
19+
},
20+
},
21+
);
22+
23+
function ButtonGroup({
24+
className,
25+
orientation,
26+
...props
27+
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
28+
return (
29+
// biome-ignore lint/a11y/useSemanticElements: its a button group
30+
<div
31+
role="group"
32+
data-slot="button-group"
33+
data-orientation={orientation}
34+
className={cn(buttonGroupVariants({ orientation }), className)}
35+
{...props}
36+
/>
37+
);
38+
}
39+
40+
function ButtonGroupText({
41+
className,
42+
asChild = false,
43+
...props
44+
}: React.ComponentProps<"div"> & {
45+
asChild?: boolean;
46+
}) {
47+
const Comp = asChild ? Slot : "div";
48+
49+
return (
50+
<Comp
51+
className={cn(
52+
"bg-muted shadow-xs flex items-center gap-2 rounded-md border px-4 text-sm font-medium [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none",
53+
className,
54+
)}
55+
{...props}
56+
/>
57+
);
58+
}
59+
60+
function ButtonGroupSeparator({
61+
className,
62+
orientation = "vertical",
63+
...props
64+
}: React.ComponentProps<typeof Separator>) {
65+
return (
66+
<Separator
67+
data-slot="button-group-separator"
68+
orientation={orientation}
69+
className={cn(
70+
"bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto",
71+
className,
72+
)}
73+
{...props}
74+
/>
75+
);
76+
}
77+
78+
export {
79+
ButtonGroup,
80+
ButtonGroupSeparator,
81+
ButtonGroupText,
82+
buttonGroupVariants,
83+
};

src/components/ui/button.tsx

Lines changed: 34 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,32 @@
11
import { Slot } from "@radix-ui/react-slot";
22
import { cva, type VariantProps } from "class-variance-authority";
3-
import * as React from "react";
3+
import type * as React from "react";
4+
45
import { cn } from "~/utils/cn";
56

67
const buttonVariants = cva(
7-
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background",
8+
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
89
{
910
variants: {
1011
variant: {
11-
default: "bg-primary text-primary-foreground hover:bg-primary/80",
12+
default: "bg-primary text-primary-foreground hover:bg-primary/90",
1213
destructive:
13-
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
14+
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
1415
outline:
15-
"border border-input hover:bg-accent hover:text-accent-foreground",
16+
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
1617
secondary:
1718
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
18-
ghost: "hover:bg-accent hover:text-accent-foreground",
19-
link: "underline-offset-4 hover:underline text-primary",
19+
ghost:
20+
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
21+
link: "text-primary underline-offset-4 hover:underline",
2022
},
2123
size: {
22-
default: "h-10 py-2 px-4",
23-
sm: "h-9 px-3 rounded-md",
24-
lg: "h-11 px-8 rounded-md",
24+
default: "h-9 px-4 py-2 has-[>svg]:px-3",
25+
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
26+
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
27+
icon: "size-9",
28+
"icon-sm": "size-8",
29+
"icon-lg": "size-10",
2530
},
2631
},
2732
defaultVariants: {
@@ -31,24 +36,25 @@ const buttonVariants = cva(
3136
},
3237
);
3338

34-
export interface ButtonProps
35-
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
36-
VariantProps<typeof buttonVariants> {
37-
asChild?: boolean;
38-
}
39+
function Button({
40+
className,
41+
variant,
42+
size,
43+
asChild = false,
44+
...props
45+
}: React.ComponentProps<"button"> &
46+
VariantProps<typeof buttonVariants> & {
47+
asChild?: boolean;
48+
}) {
49+
const Comp = asChild ? Slot : "button";
3950

40-
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
41-
({ className, variant, size, asChild = false, ...props }, ref) => {
42-
const Comp = asChild ? Slot : "button";
43-
return (
44-
<Comp
45-
className={cn(buttonVariants({ variant, size, className }))}
46-
ref={ref}
47-
{...props}
48-
/>
49-
);
50-
},
51-
);
52-
Button.displayName = "Button";
51+
return (
52+
<Comp
53+
data-slot="button"
54+
className={cn(buttonVariants({ variant, size, className }))}
55+
{...props}
56+
/>
57+
);
58+
}
5359

5460
export { Button, buttonVariants };

src/components/ui/separator.tsx

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,26 @@
11
import * as SeparatorPrimitive from "@radix-ui/react-separator";
2-
import * as React from "react";
2+
import type * as React from "react";
3+
34
import { cn } from "~/utils/cn";
45

5-
export const Separator = React.forwardRef<
6-
React.ElementRef<typeof SeparatorPrimitive.Root>,
7-
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
8-
>(
9-
(
10-
{ className, orientation = "horizontal", decorative = true, ...props },
11-
ref,
12-
) => (
6+
function Separator({
7+
className,
8+
orientation = "horizontal",
9+
decorative = true,
10+
...props
11+
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
12+
return (
1313
<SeparatorPrimitive.Root
14-
ref={ref}
14+
data-slot="separator"
1515
decorative={decorative}
1616
orientation={orientation}
1717
className={cn(
18-
"shrink-0 bg-border",
19-
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
18+
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
2019
className,
2120
)}
2221
{...props}
2322
/>
24-
),
25-
);
26-
Separator.displayName = SeparatorPrimitive.Root.displayName;
23+
);
24+
}
25+
26+
export { Separator };

0 commit comments

Comments
 (0)