diff --git a/web/src/components/MemoEditor/ActionButton/InsertMenu.tsx b/web/src/components/MemoEditor/ActionButton/InsertMenu.tsx index c3a4d99db..39cee89fc 100644 --- a/web/src/components/MemoEditor/ActionButton/InsertMenu.tsx +++ b/web/src/components/MemoEditor/ActionButton/InsertMenu.tsx @@ -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(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([]); - - // Location state const [locationDialogOpen, setLocationDialogOpen] = useState(false); - const [locationInitialized, setLocationInitialized] = useState(false); - const [locationPlaceholder, setLocationPlaceholder] = useState(props.location?.placeholder || ""); - const [locationPosition, setLocationPosition] = useState( - 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} - {highlighted} - {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) => { {t("common.upload")} - + setLinkDialogOpen(true)}> {t("tooltip.link-memo")} @@ -306,113 +128,29 @@ const InsertMenu = observer((props: Props) => { accept="*" /> - {/* Link memo dialog */} - - - - {t("tooltip.link-memo")} - -
- setSearchText(e.target.value)} - className="!text-sm" - /> -
- {filteredMemos.length === 0 ? ( -
- {isFetching ? "Loading..." : t("reference.no-memos-found")} -
- ) : ( - filteredMemos.map((memo) => ( -
addMemoRelation(memo)} - > -
-

{memo.displayTime?.toLocaleString()}

-

- {searchText ? getHighlightedContent(memo.content) : memo.snippet} -

-
-
- )) - )} -
-
-
-
+ - {/* Location dialog */} - - - - {t("tooltip.select-location")} - -
-
- -
-
-
- - handleLatChange(e.target.value)} - className="h-9" - /> -
-
- - handleLngChange(e.target.value)} - className="h-9" - /> -
-
-
- -