Commit 638b22a2 authored by Claude's avatar Claude

chore: implement InsertMenu with file upload and memo linking functionality

parent 93964827
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>
);
};
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>
);
};
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";
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[];
}
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,
};
};
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,
};
};
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,
};
};
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment