chore: implement InsertMenu with file upload and memo linking functionality

main
Claude 16 hours ago
parent 93964827ad
commit 638b22a20d

@ -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}
/>
</>
);
});

@ -0,0 +1,68 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Memo } from "@/types/proto/api/v1/memo_service";
import { useTranslate } from "@/utils/i18n";
interface LinkMemoDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
searchText: string;
onSearchChange: (text: string) => void;
filteredMemos: Memo[];
isFetching: boolean;
onSelectMemo: (memo: Memo) => void;
getHighlightedContent: (content: string) => React.ReactNode;
}
export const LinkMemoDialog = ({
open,
onOpenChange,
searchText,
onSearchChange,
filteredMemos,
isFetching,
onSelectMemo,
getHighlightedContent,
}: LinkMemoDialogProps) => {
const t = useTranslate();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<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) => onSearchChange(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={() => onSelectMemo(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>
);
};

@ -0,0 +1,108 @@
import { LatLng } from "leaflet";
import LeafletMap from "@/components/LeafletMap";
import { Button } from "@/components/ui/button";
import { Dialog, DialogClose, DialogContent } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useTranslate } from "@/utils/i18n";
import { LocationState } from "./types";
interface LocationDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
state: LocationState;
locationInitialized: boolean;
onPositionChange: (position: LatLng) => void;
onLatChange: (value: string) => void;
onLngChange: (value: string) => void;
onPlaceholderChange: (value: string) => void;
onCancel: () => void;
onConfirm: () => void;
}
export const LocationDialog = ({
open,
onOpenChange,
state,
locationInitialized,
onPositionChange,
onLatChange,
onLngChange,
onPlaceholderChange,
onCancel,
onConfirm,
}: LocationDialogProps) => {
const t = useTranslate();
const { placeholder, position, latInput, lngInput } = state;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[min(28rem,calc(100vw-2rem))] !p-0">
<DialogClose className="hidden"></DialogClose>
<div className="flex flex-col">
<div className="w-full h-64 overflow-hidden rounded-t-md bg-muted/30">
<LeafletMap key={JSON.stringify(locationInitialized)} latlng={position} onChange={onPositionChange} />
</div>
<div className="w-full flex flex-col p-3 gap-3">
<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) => onLatChange(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) => onLngChange(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={placeholder}
disabled={!position}
onChange={(e) => onPlaceholderChange(e.target.value)}
className="min-h-16"
/>
</div>
<div className="w-full flex items-center justify-end gap-2">
<Button variant="ghost" onClick={onCancel}>
{t("common.close")}
</Button>
<Button onClick={onConfirm} disabled={!position || placeholder.trim().length === 0}>
{t("common.confirm")}
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
};

@ -0,0 +1,6 @@
export { LinkMemoDialog } from "./LinkMemoDialog";
export { LocationDialog } from "./LocationDialog";
export { useFileUpload } from "./useFileUpload";
export { useLinkMemo } from "./useLinkMemo";
export { useLocation } from "./useLocation";
export type { LocationState, LinkMemoState } from "./types";

@ -0,0 +1,15 @@
import { LatLng } from "leaflet";
import { Memo } from "@/types/proto/api/v1/memo_service";
export interface LocationState {
placeholder: string;
position?: LatLng;
latInput: string;
lngInput: string;
}
export interface LinkMemoState {
searchText: string;
isFetching: boolean;
fetchedMemos: Memo[];
}

@ -0,0 +1,53 @@
import mime from "mime";
import { useRef, useState } from "react";
import { toast } from "react-hot-toast";
import { attachmentStore } from "@/store";
import { Attachment } from "@/types/proto/api/v1/attachment_service";
export const useFileUpload = (onUploadComplete: (attachments: Attachment[]) => void) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploadingFlag, setUploadingFlag] = useState(false);
const handleFileInputChange = async () => {
if (!fileInputRef.current?.files || fileInputRef.current.files.length === 0 || uploadingFlag) {
return;
}
setUploadingFlag(true);
const createdAttachmentList: Attachment[] = [];
try {
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);
}
onUploadComplete(createdAttachmentList);
} catch (error: any) {
console.error(error);
toast.error(error.details);
} finally {
setUploadingFlag(false);
}
};
const handleUploadClick = () => {
fileInputRef.current?.click();
};
return {
fileInputRef,
uploadingFlag,
handleFileInputChange,
handleUploadClick,
};
};

@ -0,0 +1,97 @@
import { useState } from "react";
import useDebounce from "react-use/lib/useDebounce";
import { memoServiceClient } from "@/grpcweb";
import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts";
import useCurrentUser from "@/hooks/useCurrentUser";
import { extractUserIdFromName } from "@/store/common";
import { Memo, MemoRelation, MemoRelation_Memo, MemoRelation_Type } from "@/types/proto/api/v1/memo_service";
interface UseLinkMemoParams {
isOpen: boolean;
currentMemoName?: string;
existingRelations: MemoRelation[];
onAddRelation: (relation: MemoRelation) => void;
}
export const useLinkMemo = ({ isOpen, currentMemoName, existingRelations, onAddRelation }: UseLinkMemoParams) => {
const user = useCurrentUser();
const [searchText, setSearchText] = useState("");
const [isFetching, setIsFetching] = useState(true);
const [fetchedMemos, setFetchedMemos] = useState<Memo[]>([]);
const filteredMemos = fetchedMemos.filter(
(memo) => memo.name !== currentMemoName && !existingRelations.some((relation) => relation.relatedMemo?.name === memo.name),
);
useDebounce(
async () => {
if (!isOpen) return;
setIsFetching(true);
try {
const conditions = [`creator_id == ${extractUserIdFromName(user.name)}`];
if (searchText) {
conditions.push(`content.contains("${searchText}")`);
}
const { memos } = await memoServiceClient.listMemos({
pageSize: DEFAULT_LIST_MEMOS_PAGE_SIZE,
filter: conditions.join(" && "),
});
setFetchedMemos(memos);
} catch (error) {
console.error(error);
} finally {
setIsFetching(false);
}
},
300,
[isOpen, searchText],
);
const addMemoRelation = (memo: Memo) => {
const relation = MemoRelation.fromPartial({
type: MemoRelation_Type.REFERENCE,
relatedMemo: MemoRelation_Memo.fromPartial({
name: memo.name,
snippet: memo.snippet,
}),
});
onAddRelation(relation);
};
const getHighlightedContent = (content: string): React.ReactNode => {
if (!searchText) return content;
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}
</>
);
};
return {
searchText,
setSearchText,
isFetching,
filteredMemos,
addMemoRelation,
getHighlightedContent,
};
};

@ -0,0 +1,82 @@
import { LatLng } from "leaflet";
import { useState } from "react";
import { Location } from "@/types/proto/api/v1/memo_service";
import { LocationState } from "./types";
export const useLocation = (initialLocation?: Location) => {
const [locationInitialized, setLocationInitialized] = useState(false);
const [state, setState] = useState<LocationState>({
placeholder: initialLocation?.placeholder || "",
position: initialLocation ? new LatLng(initialLocation.latitude, initialLocation.longitude) : undefined,
latInput: initialLocation ? String(initialLocation.latitude) : "",
lngInput: initialLocation ? String(initialLocation.longitude) : "",
});
const updatePosition = (position?: LatLng) => {
setState((prev) => ({
...prev,
position,
latInput: position ? String(position.lat) : "",
lngInput: position ? String(position.lng) : "",
}));
};
const handlePositionChange = (position: LatLng) => {
if (!locationInitialized) {
setLocationInitialized(true);
}
updatePosition(position);
};
const handleLatChange = (value: string) => {
setState((prev) => ({ ...prev, latInput: value }));
const lat = parseFloat(value);
if (!isNaN(lat) && lat >= -90 && lat <= 90 && state.position) {
updatePosition(new LatLng(lat, state.position.lng));
}
};
const handleLngChange = (value: string) => {
setState((prev) => ({ ...prev, lngInput: value }));
const lng = parseFloat(value);
if (!isNaN(lng) && lng >= -180 && lng <= 180 && state.position) {
updatePosition(new LatLng(state.position.lat, lng));
}
};
const setPlaceholder = (placeholder: string) => {
setState((prev) => ({ ...prev, placeholder }));
};
const reset = () => {
setState({
placeholder: "",
position: undefined,
latInput: "",
lngInput: "",
});
setLocationInitialized(false);
};
const getLocation = (): Location | undefined => {
if (!state.position || !state.placeholder.trim()) {
return undefined;
}
return Location.fromPartial({
latitude: state.position.lat,
longitude: state.position.lng,
placeholder: state.placeholder,
});
};
return {
state,
locationInitialized,
handlePositionChange,
handleLatChange,
handleLngChange,
setPlaceholder,
reset,
getLocation,
};
};
Loading…
Cancel
Save