mirror of https://github.com/usememos/memos.git
chore: implement InsertMenu with file upload and memo linking functionality
This commit is contained in:
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 { 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);
|
||||
},
|
||||
});
|
||||
|
||||
const location = useLocation(props.location);
|
||||
|
||||
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);
|
||||
},
|
||||
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 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);
|
||||
};
|
||||
|
||||
// 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));
|
||||
location.handlePositionChange(position);
|
||||
|
||||
const lat = position.lat;
|
||||
const lng = position.lng;
|
||||
|
||||
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"
|
||||
<LinkMemoDialog
|
||||
open={linkDialogOpen}
|
||||
onOpenChange={setLinkDialogOpen}
|
||||
searchText={linkMemo.searchText}
|
||||
onSearchChange={linkMemo.setSearchText}
|
||||
filteredMemos={linkMemo.filteredMemos}
|
||||
isFetching={linkMemo.isFetching}
|
||||
onSelectMemo={linkMemo.addMemoRelation}
|
||||
getHighlightedContent={linkMemo.getHighlightedContent}
|
||||
/>
|
||||
<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>
|
||||
|
||||
{/* 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"
|
||||
<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}
|
||||
/>
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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…
Reference in New Issue