|
|
|
|
@ -1,27 +1,19 @@
|
|
|
|
|
import { LatLng } from "leaflet";
|
|
|
|
|
import { uniqBy } from "lodash-es";
|
|
|
|
|
import { LinkIcon, LoaderIcon, MapPinIcon, PaperclipIcon, PlusIcon } from "lucide-react";
|
|
|
|
|
import mime from "mime";
|
|
|
|
|
import { observer } from "mobx-react-lite";
|
|
|
|
|
import { useContext, useRef, useState } from "react";
|
|
|
|
|
import { toast } from "react-hot-toast";
|
|
|
|
|
import useDebounce from "react-use/lib/useDebounce";
|
|
|
|
|
import LeafletMap from "@/components/LeafletMap";
|
|
|
|
|
import { useContext, useState } from "react";
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
|
|
|
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
|
|
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
|
import { Label } from "@/components/ui/label";
|
|
|
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
|
|
|
import { memoServiceClient } from "@/grpcweb";
|
|
|
|
|
import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts";
|
|
|
|
|
import useCurrentUser from "@/hooks/useCurrentUser";
|
|
|
|
|
import { attachmentStore } from "@/store";
|
|
|
|
|
import { extractUserIdFromName } from "@/store/common";
|
|
|
|
|
import { Attachment } from "@/types/proto/api/v1/attachment_service";
|
|
|
|
|
import { Location, Memo, MemoRelation_Memo, MemoRelation_Type } from "@/types/proto/api/v1/memo_service";
|
|
|
|
|
import { Location, MemoRelation } from "@/types/proto/api/v1/memo_service";
|
|
|
|
|
import { useTranslate } from "@/utils/i18n";
|
|
|
|
|
import { MemoEditorContext } from "../types";
|
|
|
|
|
import { LinkMemoDialog } from "./InsertMenu/LinkMemoDialog";
|
|
|
|
|
import { LocationDialog } from "./InsertMenu/LocationDialog";
|
|
|
|
|
import { useFileUpload } from "./InsertMenu/useFileUpload";
|
|
|
|
|
import { useLinkMemo } from "./InsertMenu/useLinkMemo";
|
|
|
|
|
import { useLocation } from "./InsertMenu/useLocation";
|
|
|
|
|
|
|
|
|
|
interface Props {
|
|
|
|
|
isUploading?: boolean;
|
|
|
|
|
@ -32,242 +24,72 @@ interface Props {
|
|
|
|
|
const InsertMenu = observer((props: Props) => {
|
|
|
|
|
const t = useTranslate();
|
|
|
|
|
const context = useContext(MemoEditorContext);
|
|
|
|
|
const user = useCurrentUser();
|
|
|
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
|
|
|
|
|
|
// Upload state
|
|
|
|
|
const [uploadingFlag, setUploadingFlag] = useState(false);
|
|
|
|
|
|
|
|
|
|
// Link memo state
|
|
|
|
|
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
|
|
|
|
|
const [searchText, setSearchText] = useState("");
|
|
|
|
|
const [isFetching, setIsFetching] = useState(true);
|
|
|
|
|
const [fetchedMemos, setFetchedMemos] = useState<Memo[]>([]);
|
|
|
|
|
|
|
|
|
|
// Location state
|
|
|
|
|
const [locationDialogOpen, setLocationDialogOpen] = useState(false);
|
|
|
|
|
const [locationInitialized, setLocationInitialized] = useState(false);
|
|
|
|
|
const [locationPlaceholder, setLocationPlaceholder] = useState(props.location?.placeholder || "");
|
|
|
|
|
const [locationPosition, setLocationPosition] = useState<LatLng | undefined>(
|
|
|
|
|
props.location ? new LatLng(props.location.latitude, props.location.longitude) : undefined,
|
|
|
|
|
);
|
|
|
|
|
const [latInput, setLatInput] = useState(props.location ? String(props.location.latitude) : "");
|
|
|
|
|
const [lngInput, setLngInput] = useState(props.location ? String(props.location.longitude) : "");
|
|
|
|
|
|
|
|
|
|
const isUploading = uploadingFlag || props.isUploading;
|
|
|
|
|
|
|
|
|
|
// File upload handler
|
|
|
|
|
const handleFileInputChange = async () => {
|
|
|
|
|
if (!fileInputRef.current || !fileInputRef.current.files || fileInputRef.current.files.length === 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (uploadingFlag) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setUploadingFlag(true);
|
|
|
|
|
|
|
|
|
|
const createdAttachmentList: Attachment[] = [];
|
|
|
|
|
try {
|
|
|
|
|
if (!fileInputRef.current || !fileInputRef.current.files) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
for (const file of fileInputRef.current.files) {
|
|
|
|
|
const { name: filename, size, type } = file;
|
|
|
|
|
const buffer = new Uint8Array(await file.arrayBuffer());
|
|
|
|
|
const attachment = await attachmentStore.createAttachment({
|
|
|
|
|
attachment: Attachment.fromPartial({
|
|
|
|
|
filename,
|
|
|
|
|
size,
|
|
|
|
|
type: type || mime.getType(filename) || "text/plain",
|
|
|
|
|
content: buffer,
|
|
|
|
|
}),
|
|
|
|
|
attachmentId: "",
|
|
|
|
|
});
|
|
|
|
|
createdAttachmentList.push(attachment);
|
|
|
|
|
}
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
console.error(error);
|
|
|
|
|
toast.error(error.details);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
context.setAttachmentList([...context.attachmentList, ...createdAttachmentList]);
|
|
|
|
|
setUploadingFlag(false);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleUploadClick = () => {
|
|
|
|
|
fileInputRef.current?.click();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Link memo handlers
|
|
|
|
|
const filteredMemos = fetchedMemos.filter(
|
|
|
|
|
(memo) => memo.name !== context.memoName && !context.relationList.some((relation) => relation.relatedMemo?.name === memo.name),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
useDebounce(
|
|
|
|
|
async () => {
|
|
|
|
|
if (!linkDialogOpen) return;
|
|
|
|
|
|
|
|
|
|
setIsFetching(true);
|
|
|
|
|
try {
|
|
|
|
|
const conditions = [`creator_id == ${extractUserIdFromName(user.name)}`];
|
|
|
|
|
if (searchText) {
|
|
|
|
|
conditions.push(`content.contains("${searchText}")`);
|
|
|
|
|
}
|
|
|
|
|
const { memos } = await memoServiceClient.listMemos({
|
|
|
|
|
filter: conditions.join(" && "),
|
|
|
|
|
pageSize: DEFAULT_LIST_MEMOS_PAGE_SIZE,
|
|
|
|
|
});
|
|
|
|
|
setFetchedMemos(memos);
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
toast.error(error.details);
|
|
|
|
|
console.error(error);
|
|
|
|
|
}
|
|
|
|
|
setIsFetching(false);
|
|
|
|
|
const { fileInputRef, uploadingFlag, handleFileInputChange, handleUploadClick } = useFileUpload((attachments: Attachment[]) => {
|
|
|
|
|
context.setAttachmentList([...context.attachmentList, ...attachments]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const linkMemo = useLinkMemo({
|
|
|
|
|
isOpen: linkDialogOpen,
|
|
|
|
|
currentMemoName: context.memoName,
|
|
|
|
|
existingRelations: context.relationList,
|
|
|
|
|
onAddRelation: (relation: MemoRelation) => {
|
|
|
|
|
context.setRelationList(uniqBy([...context.relationList, relation], (r) => r.relatedMemo?.name));
|
|
|
|
|
setLinkDialogOpen(false);
|
|
|
|
|
},
|
|
|
|
|
300,
|
|
|
|
|
[linkDialogOpen, searchText],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const getHighlightedContent = (content: string) => {
|
|
|
|
|
const index = content.toLowerCase().indexOf(searchText.toLowerCase());
|
|
|
|
|
if (index === -1) {
|
|
|
|
|
return content;
|
|
|
|
|
}
|
|
|
|
|
let before = content.slice(0, index);
|
|
|
|
|
if (before.length > 20) {
|
|
|
|
|
before = "..." + before.slice(before.length - 20);
|
|
|
|
|
}
|
|
|
|
|
const highlighted = content.slice(index, index + searchText.length);
|
|
|
|
|
let after = content.slice(index + searchText.length);
|
|
|
|
|
if (after.length > 20) {
|
|
|
|
|
after = after.slice(0, 20) + "...";
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
{before}
|
|
|
|
|
<mark className="font-medium">{highlighted}</mark>
|
|
|
|
|
{after}
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
const location = useLocation(props.location);
|
|
|
|
|
|
|
|
|
|
const addMemoRelation = (memo: Memo) => {
|
|
|
|
|
context.setRelationList(
|
|
|
|
|
uniqBy(
|
|
|
|
|
[
|
|
|
|
|
{
|
|
|
|
|
memo: MemoRelation_Memo.fromPartial({ name: memo.name }),
|
|
|
|
|
relatedMemo: MemoRelation_Memo.fromPartial({ name: memo.name }),
|
|
|
|
|
type: MemoRelation_Type.REFERENCE,
|
|
|
|
|
},
|
|
|
|
|
...context.relationList,
|
|
|
|
|
].filter((relation) => relation.relatedMemo !== context.memoName),
|
|
|
|
|
"relatedMemo",
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
setLinkDialogOpen(false);
|
|
|
|
|
setSearchText("");
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleLinkMemoClick = () => {
|
|
|
|
|
setLinkDialogOpen(true);
|
|
|
|
|
};
|
|
|
|
|
const isUploading = uploadingFlag || props.isUploading;
|
|
|
|
|
|
|
|
|
|
// Location handlers
|
|
|
|
|
const handleLocationClick = () => {
|
|
|
|
|
setLocationDialogOpen(true);
|
|
|
|
|
if (!props.location && !locationInitialized) {
|
|
|
|
|
const handleError = (error: any) => {
|
|
|
|
|
setLocationInitialized(true);
|
|
|
|
|
console.error("Geolocation error:", error);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (!props.location && !location.locationInitialized) {
|
|
|
|
|
if (navigator.geolocation) {
|
|
|
|
|
navigator.geolocation.getCurrentPosition(
|
|
|
|
|
(position) => {
|
|
|
|
|
const lat = position.coords.latitude;
|
|
|
|
|
const lng = position.coords.longitude;
|
|
|
|
|
setLocationPosition(new LatLng(lat, lng));
|
|
|
|
|
setLatInput(String(lat));
|
|
|
|
|
setLngInput(String(lng));
|
|
|
|
|
setLocationInitialized(true);
|
|
|
|
|
location.handlePositionChange(new LatLng(position.coords.latitude, position.coords.longitude));
|
|
|
|
|
},
|
|
|
|
|
(error) => {
|
|
|
|
|
handleError(error);
|
|
|
|
|
console.error("Geolocation error:", error);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
handleError("Geolocation is not supported by this browser.");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleLocationConfirm = () => {
|
|
|
|
|
if (locationPosition && locationPlaceholder.trim().length > 0) {
|
|
|
|
|
props.onLocationChange(
|
|
|
|
|
Location.fromPartial({
|
|
|
|
|
placeholder: locationPlaceholder,
|
|
|
|
|
latitude: locationPosition.lat,
|
|
|
|
|
longitude: locationPosition.lng,
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
const newLocation = location.getLocation();
|
|
|
|
|
if (newLocation) {
|
|
|
|
|
props.onLocationChange(newLocation);
|
|
|
|
|
setLocationDialogOpen(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleLocationCancel = () => {
|
|
|
|
|
location.reset();
|
|
|
|
|
setLocationDialogOpen(false);
|
|
|
|
|
// Reset to current location
|
|
|
|
|
if (props.location) {
|
|
|
|
|
setLocationPlaceholder(props.location.placeholder);
|
|
|
|
|
setLocationPosition(new LatLng(props.location.latitude, props.location.longitude));
|
|
|
|
|
setLatInput(String(props.location.latitude));
|
|
|
|
|
setLngInput(String(props.location.longitude));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Update position when lat/lng inputs change
|
|
|
|
|
const handleLatChange = (value: string) => {
|
|
|
|
|
setLatInput(value);
|
|
|
|
|
const lat = parseFloat(value);
|
|
|
|
|
const lng = parseFloat(lngInput);
|
|
|
|
|
if (Number.isFinite(lat) && Number.isFinite(lng) && lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180) {
|
|
|
|
|
setLocationPosition(new LatLng(lat, lng));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleLngChange = (value: string) => {
|
|
|
|
|
setLngInput(value);
|
|
|
|
|
const lat = parseFloat(latInput);
|
|
|
|
|
const lng = parseFloat(value);
|
|
|
|
|
if (Number.isFinite(lat) && Number.isFinite(lng) && lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180) {
|
|
|
|
|
setLocationPosition(new LatLng(lat, lng));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Reverse geocoding when position changes
|
|
|
|
|
const handlePositionChange = (position: LatLng) => {
|
|
|
|
|
setLocationPosition(position);
|
|
|
|
|
setLatInput(String(position.lat));
|
|
|
|
|
setLngInput(String(position.lng));
|
|
|
|
|
|
|
|
|
|
const lat = position.lat;
|
|
|
|
|
const lng = position.lng;
|
|
|
|
|
location.handlePositionChange(position);
|
|
|
|
|
|
|
|
|
|
fetch(`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json`)
|
|
|
|
|
fetch(`https://nominatim.openstreetmap.org/reverse?lat=${position.lat}&lon=${position.lng}&format=json`)
|
|
|
|
|
.then((response) => response.json())
|
|
|
|
|
.then((data) => {
|
|
|
|
|
if (data && data.display_name) {
|
|
|
|
|
setLocationPlaceholder(data.display_name);
|
|
|
|
|
if (data?.display_name) {
|
|
|
|
|
location.setPlaceholder(data.display_name);
|
|
|
|
|
} else {
|
|
|
|
|
setLocationPlaceholder(`${lat.toFixed(6)}, ${lng.toFixed(6)}`);
|
|
|
|
|
location.setPlaceholder(`${position.lat.toFixed(6)}, ${position.lng.toFixed(6)}`);
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.catch((error) => {
|
|
|
|
|
console.error("Failed to fetch reverse geocoding data:", error);
|
|
|
|
|
setLocationPlaceholder(`${lat.toFixed(6)}, ${lng.toFixed(6)}`);
|
|
|
|
|
location.setPlaceholder(`${position.lat.toFixed(6)}, ${position.lng.toFixed(6)}`);
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
@ -284,7 +106,7 @@ const InsertMenu = observer((props: Props) => {
|
|
|
|
|
<PaperclipIcon className="w-4 h-4" />
|
|
|
|
|
{t("common.upload")}
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
<DropdownMenuItem onClick={handleLinkMemoClick}>
|
|
|
|
|
<DropdownMenuItem onClick={() => setLinkDialogOpen(true)}>
|
|
|
|
|
<LinkIcon className="w-4 h-4" />
|
|
|
|
|
{t("tooltip.link-memo")}
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
@ -306,113 +128,29 @@ const InsertMenu = observer((props: Props) => {
|
|
|
|
|
accept="*"
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{/* Link memo dialog */}
|
|
|
|
|
<Dialog open={linkDialogOpen} onOpenChange={setLinkDialogOpen}>
|
|
|
|
|
<DialogContent>
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
<DialogTitle>{t("tooltip.link-memo")}</DialogTitle>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
<div className="flex flex-col gap-3">
|
|
|
|
|
<Input
|
|
|
|
|
placeholder={t("reference.search-placeholder")}
|
|
|
|
|
value={searchText}
|
|
|
|
|
onChange={(e) => setSearchText(e.target.value)}
|
|
|
|
|
className="!text-sm"
|
|
|
|
|
/>
|
|
|
|
|
<div className="max-h-[300px] overflow-y-auto border rounded-md">
|
|
|
|
|
{filteredMemos.length === 0 ? (
|
|
|
|
|
<div className="py-8 text-center text-sm text-muted-foreground">
|
|
|
|
|
{isFetching ? "Loading..." : t("reference.no-memos-found")}
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
filteredMemos.map((memo) => (
|
|
|
|
|
<div
|
|
|
|
|
key={memo.name}
|
|
|
|
|
className="relative flex cursor-pointer items-start gap-2 border-b last:border-b-0 px-3 py-2 hover:bg-accent hover:text-accent-foreground"
|
|
|
|
|
onClick={() => addMemoRelation(memo)}
|
|
|
|
|
>
|
|
|
|
|
<div className="w-full flex flex-col justify-start items-start">
|
|
|
|
|
<p className="text-xs text-muted-foreground select-none">{memo.displayTime?.toLocaleString()}</p>
|
|
|
|
|
<p className="mt-0.5 text-sm leading-5 line-clamp-2">
|
|
|
|
|
{searchText ? getHighlightedContent(memo.content) : memo.snippet}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
<LinkMemoDialog
|
|
|
|
|
open={linkDialogOpen}
|
|
|
|
|
onOpenChange={setLinkDialogOpen}
|
|
|
|
|
searchText={linkMemo.searchText}
|
|
|
|
|
onSearchChange={linkMemo.setSearchText}
|
|
|
|
|
filteredMemos={linkMemo.filteredMemos}
|
|
|
|
|
isFetching={linkMemo.isFetching}
|
|
|
|
|
onSelectMemo={linkMemo.addMemoRelation}
|
|
|
|
|
getHighlightedContent={linkMemo.getHighlightedContent}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{/* Location dialog */}
|
|
|
|
|
<Dialog open={locationDialogOpen} onOpenChange={setLocationDialogOpen}>
|
|
|
|
|
<DialogContent className="max-w-[min(28rem,calc(100vw-2rem))]">
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
<DialogTitle>{t("tooltip.select-location")}</DialogTitle>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
<div className="flex flex-col gap-3">
|
|
|
|
|
<div className="w-full h-64 overflow-hidden rounded-md bg-muted/30">
|
|
|
|
|
<LeafletMap key={JSON.stringify(locationInitialized)} latlng={locationPosition} onChange={handlePositionChange} />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
|
|
|
<div className="grid gap-1">
|
|
|
|
|
<Label htmlFor="memo-location-lat" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
|
|
|
Lat
|
|
|
|
|
</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id="memo-location-lat"
|
|
|
|
|
placeholder="Lat"
|
|
|
|
|
type="number"
|
|
|
|
|
step="any"
|
|
|
|
|
min="-90"
|
|
|
|
|
max="90"
|
|
|
|
|
value={latInput}
|
|
|
|
|
onChange={(e) => handleLatChange(e.target.value)}
|
|
|
|
|
className="h-9"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="grid gap-1">
|
|
|
|
|
<Label htmlFor="memo-location-lng" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
|
|
|
Lng
|
|
|
|
|
</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id="memo-location-lng"
|
|
|
|
|
placeholder="Lng"
|
|
|
|
|
type="number"
|
|
|
|
|
step="any"
|
|
|
|
|
min="-180"
|
|
|
|
|
max="180"
|
|
|
|
|
value={lngInput}
|
|
|
|
|
onChange={(e) => handleLngChange(e.target.value)}
|
|
|
|
|
className="h-9"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="grid gap-1">
|
|
|
|
|
<Label htmlFor="memo-location-placeholder" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
|
|
|
{t("tooltip.select-location")}
|
|
|
|
|
</Label>
|
|
|
|
|
<Textarea
|
|
|
|
|
id="memo-location-placeholder"
|
|
|
|
|
placeholder="Choose a position first."
|
|
|
|
|
value={locationPlaceholder}
|
|
|
|
|
disabled={!locationPosition}
|
|
|
|
|
onChange={(e) => setLocationPlaceholder(e.target.value)}
|
|
|
|
|
className="min-h-16"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="w-full flex items-center justify-end gap-2">
|
|
|
|
|
<Button variant="ghost" size="sm" onClick={handleLocationCancel}>
|
|
|
|
|
{t("common.cancel")}
|
|
|
|
|
</Button>
|
|
|
|
|
<Button size="sm" onClick={handleLocationConfirm} disabled={!locationPosition || locationPlaceholder.trim().length === 0}>
|
|
|
|
|
{t("common.confirm")}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
<LocationDialog
|
|
|
|
|
open={locationDialogOpen}
|
|
|
|
|
onOpenChange={setLocationDialogOpen}
|
|
|
|
|
state={location.state}
|
|
|
|
|
locationInitialized={location.locationInitialized}
|
|
|
|
|
onPositionChange={handlePositionChange}
|
|
|
|
|
onLatChange={location.handleLatChange}
|
|
|
|
|
onLngChange={location.handleLngChange}
|
|
|
|
|
onPlaceholderChange={location.setPlaceholder}
|
|
|
|
|
onCancel={handleLocationCancel}
|
|
|
|
|
onConfirm={handleLocationConfirm}
|
|
|
|
|
/>
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|