mirror of https://github.com/usememos/memos.git
refactor: redesign memo editor action bar UI
- Replace multiple action buttons with unified InsertMenu dropdown - Consolidate upload, link memo, and location into single + button - Redesign VisibilitySelector with text-based dropdown UI - Unify badge styling for location, attachments, and links - Consistent height (h-7), padding, gaps, and border styles - Secondary foreground text color with hover states - Max width with truncation for long content - Add image thumbnails in attachment badges - Simplify button hierarchy with ghost variant for save/cancel - Remove obsolete components (TagSelector, MarkdownMenu, etc.) - Extract LocationView to separate component for better organization Fixes #5196
This commit is contained in:
parent
1ced0bcdbd
commit
93964827ad
|
|
@ -1,154 +0,0 @@
|
||||||
import { uniqBy } from "lodash-es";
|
|
||||||
import { LinkIcon } from "lucide-react";
|
|
||||||
import { useContext, useState } from "react";
|
|
||||||
import { toast } from "react-hot-toast";
|
|
||||||
import useDebounce from "react-use/lib/useDebounce";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
|
||||||
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_Memo, MemoRelation_Type } from "@/types/proto/api/v1/memo_service";
|
|
||||||
import { useTranslate } from "@/utils/i18n";
|
|
||||||
import { MemoEditorContext } from "../types";
|
|
||||||
|
|
||||||
const AddMemoRelationPopover = () => {
|
|
||||||
const t = useTranslate();
|
|
||||||
const context = useContext(MemoEditorContext);
|
|
||||||
const user = useCurrentUser();
|
|
||||||
const [searchText, setSearchText] = useState<string>("");
|
|
||||||
const [isFetching, setIsFetching] = useState<boolean>(true);
|
|
||||||
const [fetchedMemos, setFetchedMemos] = useState<Memo[]>([]);
|
|
||||||
const [popoverOpen, setPopoverOpen] = useState<boolean>(false);
|
|
||||||
|
|
||||||
const filteredMemos = fetchedMemos.filter(
|
|
||||||
(memo) => memo.name !== context.memoName && !context.relationList.some((relation) => relation.relatedMemo?.name === memo.name),
|
|
||||||
);
|
|
||||||
|
|
||||||
useDebounce(
|
|
||||||
async () => {
|
|
||||||
if (!popoverOpen) 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,
|
|
||||||
[popoverOpen, 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 addMemoRelations = async (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",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
setPopoverOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button variant="ghost" size="icon">
|
|
||||||
<LinkIcon className="size-5" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom">
|
|
||||||
<p>{t("tooltip.link-memo")}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
<PopoverContent align="center">
|
|
||||||
<div className="w-[16rem] p-1 flex flex-col justify-start items-start">
|
|
||||||
{/* Search and selection interface */}
|
|
||||||
<div className="w-full">
|
|
||||||
<Input
|
|
||||||
placeholder={t("reference.search-placeholder")}
|
|
||||||
value={searchText}
|
|
||||||
onChange={(e) => setSearchText(e.target.value)}
|
|
||||||
className="mb-2 !text-sm"
|
|
||||||
/>
|
|
||||||
<div className="max-h-[200px] overflow-y-auto">
|
|
||||||
{filteredMemos.length === 0 ? (
|
|
||||||
<div className="py-6 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 rounded-sm px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground"
|
|
||||||
onClick={() => {
|
|
||||||
addMemoRelations(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>
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AddMemoRelationPopover;
|
|
||||||
|
|
@ -0,0 +1,420 @@
|
||||||
|
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 { 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 { useTranslate } from "@/utils/i18n";
|
||||||
|
import { MemoEditorContext } from "../types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isUploading?: boolean;
|
||||||
|
location?: Location;
|
||||||
|
onLocationChange: (location?: Location) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
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 (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);
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
handleError(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,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
setLocationDialogOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLocationCancel = () => {
|
||||||
|
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;
|
||||||
|
|
||||||
|
fetch(`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json`)
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (data && data.display_name) {
|
||||||
|
setLocationPlaceholder(data.display_name);
|
||||||
|
} else {
|
||||||
|
setLocationPlaceholder(`${lat.toFixed(6)}, ${lng.toFixed(6)}`);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Failed to fetch reverse geocoding data:", error);
|
||||||
|
setLocationPlaceholder(`${lat.toFixed(6)}, ${lng.toFixed(6)}`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="icon" className="shadow-none" disabled={isUploading}>
|
||||||
|
{isUploading ? <LoaderIcon className="size-4 animate-spin" /> : <PlusIcon className="size-4" />}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start">
|
||||||
|
<DropdownMenuItem onClick={handleUploadClick}>
|
||||||
|
<PaperclipIcon className="w-4 h-4" />
|
||||||
|
{t("common.upload")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={handleLinkMemoClick}>
|
||||||
|
<LinkIcon className="w-4 h-4" />
|
||||||
|
{t("tooltip.link-memo")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={handleLocationClick}>
|
||||||
|
<MapPinIcon className="w-4 h-4" />
|
||||||
|
{t("tooltip.select-location")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
{/* Hidden file input */}
|
||||||
|
<input
|
||||||
|
className="hidden"
|
||||||
|
ref={fileInputRef}
|
||||||
|
disabled={isUploading}
|
||||||
|
onChange={handleFileInputChange}
|
||||||
|
type="file"
|
||||||
|
multiple={true}
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default InsertMenu;
|
||||||
|
|
@ -1,249 +0,0 @@
|
||||||
import { LatLng } from "leaflet";
|
|
||||||
import { MapPinIcon, XIcon } from "lucide-react";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import LeafletMap from "@/components/LeafletMap";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
|
||||||
import { Location } from "@/types/proto/api/v1/memo_service";
|
|
||||||
import { useTranslate } from "@/utils/i18n";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
location?: Location;
|
|
||||||
onChange: (location?: Location) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface State {
|
|
||||||
initialized: boolean;
|
|
||||||
placeholder: string;
|
|
||||||
position?: LatLng;
|
|
||||||
latInput: string;
|
|
||||||
lngInput: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const LocationSelector = (props: Props) => {
|
|
||||||
const t = useTranslate();
|
|
||||||
const [state, setState] = useState<State>({
|
|
||||||
initialized: false,
|
|
||||||
placeholder: props.location?.placeholder || "",
|
|
||||||
position: props.location ? new LatLng(props.location.latitude, props.location.longitude) : undefined,
|
|
||||||
latInput: props.location ? String(props.location.latitude) : "",
|
|
||||||
lngInput: props.location ? String(props.location.longitude) : "",
|
|
||||||
});
|
|
||||||
const [popoverOpen, setPopoverOpen] = useState<boolean>(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setState((state) => ({
|
|
||||||
...state,
|
|
||||||
placeholder: props.location?.placeholder || "",
|
|
||||||
position: new LatLng(props.location?.latitude || 0, props.location?.longitude || 0),
|
|
||||||
latInput: String(props.location?.latitude) || "",
|
|
||||||
lngInput: String(props.location?.longitude) || "",
|
|
||||||
}));
|
|
||||||
}, [props.location]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (popoverOpen && !props.location) {
|
|
||||||
const handleError = (error: any) => {
|
|
||||||
setState((prev) => ({ ...prev, initialized: true }));
|
|
||||||
console.error("Geolocation error:", error);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (navigator.geolocation) {
|
|
||||||
navigator.geolocation.getCurrentPosition(
|
|
||||||
(position) => {
|
|
||||||
const lat = position.coords.latitude;
|
|
||||||
const lng = position.coords.longitude;
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
position: new LatLng(lat, lng),
|
|
||||||
latInput: String(lat),
|
|
||||||
lngInput: String(lng),
|
|
||||||
initialized: true,
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
(error) => {
|
|
||||||
handleError(error);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
handleError("Geolocation is not supported by this browser.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [popoverOpen, props.location]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!state.position) {
|
|
||||||
setState((prev) => ({ ...prev, placeholder: "" }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sync lat/lng input values from position
|
|
||||||
const newLat = String(state.position.lat);
|
|
||||||
const newLng = String(state.position.lng);
|
|
||||||
if (state.latInput !== newLat || state.lngInput !== newLng) {
|
|
||||||
setState((prev) => ({ ...prev, latInput: newLat, lngInput: newLng }));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch reverse geocoding data.
|
|
||||||
const lat = state.position.lat;
|
|
||||||
const lng = state.position.lng;
|
|
||||||
|
|
||||||
fetch(`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json`)
|
|
||||||
.then((response) => response.json())
|
|
||||||
.then((data) => {
|
|
||||||
if (data && data.display_name) {
|
|
||||||
setState((prev) => ({ ...prev, placeholder: data.display_name }));
|
|
||||||
} else {
|
|
||||||
// Fallback to coordinates if no display name
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
placeholder: `${lat.toFixed(6)}, ${lng.toFixed(6)}`,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
// Silent fallback: use coordinates as placeholder when geocoding fails
|
|
||||||
console.error("Failed to fetch reverse geocoding data:", error);
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
placeholder: `${lat.toFixed(6)}, ${lng.toFixed(6)}`,
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
}, [state.position]);
|
|
||||||
|
|
||||||
// Update position when lat/lng inputs change (if valid numbers)
|
|
||||||
useEffect(() => {
|
|
||||||
const lat = parseFloat(state.latInput);
|
|
||||||
const lng = parseFloat(state.lngInput);
|
|
||||||
// Validate coordinate ranges: lat must be -90 to 90, lng must be -180 to 180
|
|
||||||
if (Number.isFinite(lat) && Number.isFinite(lng) && lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180) {
|
|
||||||
if (!state.position || state.position.lat !== lat || state.position.lng !== lng) {
|
|
||||||
setState((prev) => ({ ...prev, position: new LatLng(lat, lng) }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [state.latInput, state.lngInput]);
|
|
||||||
|
|
||||||
const onPositionChanged = (position: LatLng) => {
|
|
||||||
setState((prev) => ({ ...prev, position }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeLocation = (e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
e.preventDefault();
|
|
||||||
props.onChange(undefined);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button variant="ghost" size={props.location ? undefined : "icon"}>
|
|
||||||
<MapPinIcon className="size-5 shrink-0" />
|
|
||||||
{props.location && (
|
|
||||||
<>
|
|
||||||
<span className="ml-0.5 text-sm text-ellipsis whitespace-nowrap overflow-hidden max-w-28">
|
|
||||||
{props.location.placeholder}
|
|
||||||
</span>
|
|
||||||
<span className="ml-1 cursor-pointer hover:text-primary" onClick={removeLocation}>
|
|
||||||
<XIcon className="size-4 shrink-0" />
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
</TooltipTrigger>
|
|
||||||
{!props.location && (
|
|
||||||
<TooltipContent side="bottom">
|
|
||||||
<p>{t("tooltip.select-location")}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
)}
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
<PopoverContent align="center" className="w-[min(24rem,calc(100vw-2rem))] p-0">
|
|
||||||
<div className="flex flex-col gap-2 p-0">
|
|
||||||
<div className="w-full overflow-hidden bg-muted/30">
|
|
||||||
<LeafletMap key={JSON.stringify(state.initialized)} latlng={state.position} onChange={onPositionChanged} />
|
|
||||||
</div>
|
|
||||||
<div className="w-full space-y-3 px-2 pb-2">
|
|
||||||
<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={state.latInput}
|
|
||||||
onChange={(e) => setState((prev) => ({ ...prev, latInput: 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={state.lngInput}
|
|
||||||
onChange={(e) => setState((prev) => ({ ...prev, lngInput: 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={state.placeholder}
|
|
||||||
disabled={!state.position}
|
|
||||||
onChange={(e) => setState((prev) => ({ ...prev, placeholder: 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={() => setPopoverOpen(false)}>
|
|
||||||
{t("common.cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
props.onChange(
|
|
||||||
Location.fromPartial({
|
|
||||||
placeholder: state.placeholder,
|
|
||||||
latitude: state.position?.lat,
|
|
||||||
longitude: state.position?.lng,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
setPopoverOpen(false);
|
|
||||||
}}
|
|
||||||
disabled={!state.position || state.placeholder.trim().length === 0}
|
|
||||||
>
|
|
||||||
{t("common.confirm")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default LocationSelector;
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
import { CheckSquareIcon, Code2Icon, SquareSlashIcon } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "@/components/ui/tooltip";
|
|
||||||
import { useTranslate } from "@/utils/i18n";
|
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../../ui/dropdown-menu";
|
|
||||||
import { EditorRefActions } from "../Editor";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
editorRef: React.RefObject<EditorRefActions>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MarkdownMenu = (props: Props) => {
|
|
||||||
const { editorRef } = props;
|
|
||||||
const t = useTranslate();
|
|
||||||
|
|
||||||
const handleCodeBlockClick = () => {
|
|
||||||
if (!editorRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cursorPosition = editorRef.current.getCursorPosition();
|
|
||||||
const prevValue = editorRef.current.getContent().slice(0, cursorPosition);
|
|
||||||
if (prevValue === "" || prevValue.endsWith("\n")) {
|
|
||||||
editorRef.current.insertText("", "```\n", "\n```");
|
|
||||||
} else {
|
|
||||||
editorRef.current.insertText("", "\n```\n", "\n```");
|
|
||||||
}
|
|
||||||
setTimeout(() => {
|
|
||||||
editorRef.current?.scrollToCursor();
|
|
||||||
editorRef.current?.focus();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCheckboxClick = () => {
|
|
||||||
if (!editorRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentPosition = editorRef.current.getCursorPosition();
|
|
||||||
const currentLineNumber = editorRef.current.getCursorLineNumber();
|
|
||||||
const currentLine = editorRef.current.getLine(currentLineNumber);
|
|
||||||
let newLine = "";
|
|
||||||
let cursorChange = 0;
|
|
||||||
if (/^- \[( |x|X)\] /.test(currentLine)) {
|
|
||||||
newLine = currentLine.replace(/^- \[( |x|X)\] /, "");
|
|
||||||
cursorChange = -6;
|
|
||||||
} else if (/^\d+\. |- /.test(currentLine)) {
|
|
||||||
const match = currentLine.match(/^\d+\. |- /) ?? [""];
|
|
||||||
newLine = currentLine.replace(/^\d+\. |- /, "- [ ] ");
|
|
||||||
cursorChange = -match[0].length + 6;
|
|
||||||
} else {
|
|
||||||
newLine = "- [ ] " + currentLine;
|
|
||||||
cursorChange = 6;
|
|
||||||
}
|
|
||||||
editorRef.current.setLine(currentLineNumber, newLine);
|
|
||||||
editorRef.current.setCursorPosition(currentPosition + cursorChange);
|
|
||||||
setTimeout(() => {
|
|
||||||
editorRef.current?.scrollToCursor();
|
|
||||||
editorRef.current?.focus();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" size="icon">
|
|
||||||
<SquareSlashIcon className="size-5" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom">
|
|
||||||
<p>{t("tooltip.markdown-menu")}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
<DropdownMenuContent align="start">
|
|
||||||
<DropdownMenuItem onClick={handleCodeBlockClick}>
|
|
||||||
<Code2Icon className="w-4 h-auto text-muted-foreground" />
|
|
||||||
{t("markdown.code-block")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={handleCheckboxClick}>
|
|
||||||
<CheckSquareIcon className="w-4 h-auto text-muted-foreground" />
|
|
||||||
{t("markdown.checkbox")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<div className="px-2 -mt-1">
|
|
||||||
<a
|
|
||||||
className="text-xs text-primary hover:underline"
|
|
||||||
href="https://www.usememos.com/docs/guides/content-syntax"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
{t("markdown.content-syntax")}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MarkdownMenu;
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
import { HashIcon } from "lucide-react";
|
|
||||||
import { observer } from "mobx-react-lite";
|
|
||||||
import OverflowTip from "@/components/kit/OverflowTip";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "@/components/ui/tooltip";
|
|
||||||
import { userStore } from "@/store";
|
|
||||||
import { useTranslate } from "@/utils/i18n";
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "../../ui/popover";
|
|
||||||
import { EditorRefActions } from "../Editor";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
editorRef: React.RefObject<EditorRefActions>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TagSelector = observer((props: Props) => {
|
|
||||||
const t = useTranslate();
|
|
||||||
const { editorRef } = props;
|
|
||||||
const tags = Object.entries(userStore.state.tagCount)
|
|
||||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
||||||
.sort((a, b) => b[1] - a[1])
|
|
||||||
.map(([tag]) => tag);
|
|
||||||
|
|
||||||
const handleTagClick = (tag: string) => {
|
|
||||||
const current = editorRef.current;
|
|
||||||
if (current === null) return;
|
|
||||||
|
|
||||||
const line = current.getLine(current.getCursorLineNumber());
|
|
||||||
const lastCharOfLine = line.slice(-1);
|
|
||||||
|
|
||||||
if (lastCharOfLine !== " " && lastCharOfLine !== " " && line !== "") {
|
|
||||||
current.insertText("\n");
|
|
||||||
}
|
|
||||||
current.insertText(`#${tag} `);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover>
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button variant="ghost" size="icon">
|
|
||||||
<HashIcon className="size-5" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom">
|
|
||||||
<p>{t("tooltip.tags")}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
<PopoverContent align="start" sideOffset={2}>
|
|
||||||
{tags.length > 0 ? (
|
|
||||||
<div className="flex flex-row justify-start items-start flex-wrap px-2 max-w-48 h-auto max-h-48 overflow-y-auto gap-x-2">
|
|
||||||
{tags.map((tag) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={tag}
|
|
||||||
className="inline-flex w-auto max-w-full cursor-pointer text-base leading-6 text-muted-foreground hover:opacity-80"
|
|
||||||
onClick={() => handleTagClick(tag)}
|
|
||||||
>
|
|
||||||
<OverflowTip>#{tag}</OverflowTip>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="italic mx-2" onClick={(e) => e.stopPropagation()}>
|
|
||||||
{t("tag.no-tag-found")}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default TagSelector;
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
import { t } from "i18next";
|
|
||||||
import { LoaderIcon, PaperclipIcon } 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 { Button } from "@/components/ui/button";
|
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
|
||||||
import { attachmentStore } from "@/store";
|
|
||||||
import { Attachment } from "@/types/proto/api/v1/attachment_service";
|
|
||||||
import { MemoEditorContext } from "../types";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
isUploading?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface State {
|
|
||||||
uploadingFlag: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const UploadAttachmentButton = observer((props: Props) => {
|
|
||||||
const context = useContext(MemoEditorContext);
|
|
||||||
const [state, setState] = useState<State>({
|
|
||||||
uploadingFlag: false,
|
|
||||||
});
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const handleFileInputChange = async () => {
|
|
||||||
if (!fileInputRef.current || !fileInputRef.current.files || fileInputRef.current.files.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (state.uploadingFlag) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setState((state) => {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
uploadingFlag: 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]);
|
|
||||||
setState((state) => {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
uploadingFlag: false,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const isUploading = state.uploadingFlag || props.isUploading;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button className="relative" variant="ghost" size="icon" disabled={isUploading}>
|
|
||||||
{isUploading ? <LoaderIcon className="size-5 animate-spin" /> : <PaperclipIcon className="size-5" />}
|
|
||||||
<input
|
|
||||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
|
||||||
ref={fileInputRef}
|
|
||||||
disabled={isUploading}
|
|
||||||
onChange={handleFileInputChange}
|
|
||||||
type="file"
|
|
||||||
id="files"
|
|
||||||
multiple={true}
|
|
||||||
accept="*"
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom">
|
|
||||||
<p>{t("tooltip.upload-attachment")}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default UploadAttachmentButton;
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
|
import { CheckIcon, ChevronDownIcon } from "lucide-react";
|
||||||
import VisibilityIcon from "@/components/VisibilityIcon";
|
import VisibilityIcon from "@/components/VisibilityIcon";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
|
||||||
import { Visibility } from "@/types/proto/api/v1/memo_service";
|
import { Visibility } from "@/types/proto/api/v1/memo_service";
|
||||||
import { useTranslate } from "@/utils/i18n";
|
import { useTranslate } from "@/utils/i18n";
|
||||||
|
|
||||||
// ⬅️ ADD this line
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
value: Visibility;
|
value: Visibility;
|
||||||
onChange: (visibility: Visibility) => void;
|
onChange: (visibility: Visibility) => void;
|
||||||
|
|
@ -22,35 +20,27 @@ const VisibilitySelector = (props: Props) => {
|
||||||
{ value: Visibility.PUBLIC, label: t("memo.visibility.public") },
|
{ value: Visibility.PUBLIC, label: t("memo.visibility.public") },
|
||||||
];
|
];
|
||||||
|
|
||||||
const handleOpenChange = (open: boolean) => {
|
const currentLabel = visibilityOptions.find((option) => option.value === value)?.label || "";
|
||||||
if (props.onOpenChange) {
|
|
||||||
props.onOpenChange(open);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<DropdownMenu onOpenChange={props.onOpenChange}>
|
||||||
<Tooltip>
|
<DropdownMenuTrigger asChild>
|
||||||
<TooltipTrigger asChild>
|
<button className="inline-flex items-center gap-1.5 px-2 text-sm text-muted-foreground opacity-80 hover:opacity-100 transition-colors">
|
||||||
<Select value={value.toString()} onValueChange={onChange} onOpenChange={handleOpenChange}>
|
<VisibilityIcon visibility={value} className="opacity-60" />
|
||||||
<SelectTrigger size="xs" className="!bg-background">
|
<span>{currentLabel}</span>
|
||||||
<SelectValue />
|
<ChevronDownIcon className="w-3 h-3 opacity-60" />
|
||||||
</SelectTrigger>
|
</button>
|
||||||
<SelectContent align="end">
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
{visibilityOptions.map((option) => (
|
{visibilityOptions.map((option) => (
|
||||||
<SelectItem key={option.value} value={option.value.toString()}>
|
<DropdownMenuItem key={option.value} className="cursor-pointer gap-2" onClick={() => onChange(option.value)}>
|
||||||
<VisibilityIcon className="size-3.5" visibility={option.value} />
|
<VisibilityIcon visibility={option.value} />
|
||||||
{option.label}
|
<span className="flex-1">{option.label}</span>
|
||||||
</SelectItem>
|
{value === option.value && <CheckIcon className="w-4 h-4 text-primary" />}
|
||||||
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</DropdownMenuContent>
|
||||||
</Select>
|
</DropdownMenu>
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom">
|
|
||||||
<p>{t("tooltip.select-visibility")}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { DndContext, closestCenter, MouseSensor, TouchSensor, useSensor, useSensors, DragEndEvent } from "@dnd-kit/core";
|
import { DndContext, closestCenter, MouseSensor, TouchSensor, useSensor, useSensors, DragEndEvent } from "@dnd-kit/core";
|
||||||
import { arrayMove, SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
import { arrayMove, SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||||
import { XIcon } from "lucide-react";
|
import { FileIcon, XIcon } from "lucide-react";
|
||||||
import { Attachment } from "@/types/proto/api/v1/attachment_service";
|
import { Attachment } from "@/types/proto/api/v1/attachment_service";
|
||||||
import AttachmentIcon from "../AttachmentIcon";
|
import { getAttachmentThumbnailUrl, getAttachmentType } from "@/utils/attachment";
|
||||||
import SortableItem from "./SortableItem";
|
import SortableItem from "./SortableItem";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -38,14 +38,25 @@ const AttachmentListView = (props: Props) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={attachment.name}
|
key={attachment.name}
|
||||||
className="max-w-full w-auto flex flex-row justify-start items-center flex-nowrap gap-x-1 bg-muted px-2 py-1 rounded hover:shadow-sm text-muted-foreground"
|
className="group relative inline-flex items-center gap-1.5 px-2 h-7 rounded-md border border-border bg-background text-secondary-foreground text-xs transition-colors hover:bg-accent"
|
||||||
>
|
>
|
||||||
<SortableItem id={attachment.name} className="flex flex-row justify-start items-center gap-x-1">
|
<SortableItem id={attachment.name} className="flex items-center gap-1.5 min-w-0">
|
||||||
<AttachmentIcon attachment={attachment} className="w-4! h-4! opacity-100!" />
|
{getAttachmentType(attachment) === "image/*" ? (
|
||||||
<span className="text-sm max-w-32 truncate">{attachment.filename}</span>
|
<img
|
||||||
|
src={getAttachmentThumbnailUrl(attachment)}
|
||||||
|
alt={attachment.filename}
|
||||||
|
className="w-5 h-5 shrink-0 object-cover rounded"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FileIcon className="w-3.5 h-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<span className="truncate max-w-[160px]">{attachment.filename}</span>
|
||||||
</SortableItem>
|
</SortableItem>
|
||||||
<button className="shrink-0" onClick={() => handleDeleteAttachment(attachment.name)}>
|
<button
|
||||||
<XIcon className="w-4 h-auto cursor-pointer opacity-60 hover:opacity-100" />
|
className="shrink-0 rounded hover:bg-accent transition-colors p-0.5"
|
||||||
|
onClick={() => handleDeleteAttachment(attachment.name)}
|
||||||
|
>
|
||||||
|
<XIcon className="w-3 h-3 text-muted-foreground hover:text-foreground" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { MapPinIcon, XIcon } from "lucide-react";
|
||||||
|
import { Location } from "@/types/proto/api/v1/memo_service";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
location?: Location;
|
||||||
|
onRemove: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LocationView = (props: Props) => {
|
||||||
|
if (!props.location) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full flex flex-row flex-wrap gap-2 mt-2">
|
||||||
|
<div className="group relative inline-flex items-center gap-1.5 px-2 h-7 rounded-md border border-border bg-background text-secondary-foreground text-xs transition-colors hover:bg-accent">
|
||||||
|
<MapPinIcon className="w-3.5 h-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="truncate max-w-[160px]">{props.location.placeholder}</span>
|
||||||
|
<button
|
||||||
|
className="shrink-0 rounded hover:bg-accent transition-colors p-0.5"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
props.onRemove();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<XIcon className="w-3 h-3 text-muted-foreground hover:text-foreground" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LocationView;
|
||||||
|
|
@ -37,12 +37,12 @@ const RelationListView = observer((props: Props) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={memo.name}
|
key={memo.name}
|
||||||
className="w-auto max-w-xs overflow-hidden flex flex-row justify-start items-center bg-muted hover:opacity-80 rounded-md text-sm p-1 px-2 text-muted-foreground cursor-pointer hover:line-through"
|
className="group relative inline-flex items-center gap-1.5 px-2 h-7 rounded-md border border-border bg-background text-secondary-foreground text-xs transition-colors hover:bg-accent cursor-pointer"
|
||||||
onClick={() => handleDeleteRelation(memo)}
|
onClick={() => handleDeleteRelation(memo)}
|
||||||
>
|
>
|
||||||
<LinkIcon className="w-4 h-auto shrink-0 opacity-80" />
|
<LinkIcon className="w-3.5 h-3.5 shrink-0 text-muted-foreground" />
|
||||||
<span className="mx-1 max-w-full text-ellipsis whitespace-nowrap overflow-hidden">{memo.snippet}</span>
|
<span className="truncate max-w-[160px]">{memo.snippet}</span>
|
||||||
<XIcon className="w-4 h-auto cursor-pointer shrink-0 opacity-60 hover:opacity-100" />
|
<XIcon className="w-3 h-3 shrink-0 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,7 @@ import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import useLocalStorage from "react-use/lib/useLocalStorage";
|
import useLocalStorage from "react-use/lib/useLocalStorage";
|
||||||
import VisibilityIcon from "@/components/VisibilityIcon";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
|
||||||
import { memoServiceClient } from "@/grpcweb";
|
import { memoServiceClient } from "@/grpcweb";
|
||||||
import { TAB_SPACE_WIDTH } from "@/helpers/consts";
|
import { TAB_SPACE_WIDTH } from "@/helpers/consts";
|
||||||
import { isValidUrl } from "@/helpers/utils";
|
import { isValidUrl } from "@/helpers/utils";
|
||||||
|
|
@ -22,13 +20,11 @@ import { Location, Memo, MemoRelation, MemoRelation_Type, Visibility } from "@/t
|
||||||
import { useTranslate } from "@/utils/i18n";
|
import { useTranslate } from "@/utils/i18n";
|
||||||
import { convertVisibilityFromString } from "@/utils/memo";
|
import { convertVisibilityFromString } from "@/utils/memo";
|
||||||
import DateTimeInput from "../DateTimeInput";
|
import DateTimeInput from "../DateTimeInput";
|
||||||
import AddMemoRelationPopover from "./ActionButton/AddMemoRelationPopover";
|
import InsertMenu from "./ActionButton/InsertMenu";
|
||||||
import LocationSelector from "./ActionButton/LocationSelector";
|
import VisibilitySelector from "./ActionButton/VisibilitySelector";
|
||||||
import MarkdownMenu from "./ActionButton/MarkdownMenu";
|
|
||||||
import TagSelector from "./ActionButton/TagSelector";
|
|
||||||
import UploadAttachmentButton from "./ActionButton/UploadAttachmentButton";
|
|
||||||
import AttachmentListView from "./AttachmentListView";
|
import AttachmentListView from "./AttachmentListView";
|
||||||
import Editor, { EditorRefActions } from "./Editor";
|
import Editor, { EditorRefActions } from "./Editor";
|
||||||
|
import LocationView from "./LocationView";
|
||||||
import RelationListView from "./RelationListView";
|
import RelationListView from "./RelationListView";
|
||||||
import { handleEditorKeydownWithMarkdownShortcuts, hyperlinkHighlightedText } from "./handlers";
|
import { handleEditorKeydownWithMarkdownShortcuts, hyperlinkHighlightedText } from "./handlers";
|
||||||
import { MemoEditorContext } from "./types";
|
import { MemoEditorContext } from "./types";
|
||||||
|
|
@ -494,17 +490,23 @@ const MemoEditor = observer((props: Props) => {
|
||||||
onCompositionEnd={handleCompositionEnd}
|
onCompositionEnd={handleCompositionEnd}
|
||||||
>
|
>
|
||||||
<Editor ref={editorRef} {...editorConfig} />
|
<Editor ref={editorRef} {...editorConfig} />
|
||||||
|
<LocationView
|
||||||
|
location={state.location}
|
||||||
|
onRemove={() =>
|
||||||
|
setState((prevState) => ({
|
||||||
|
...prevState,
|
||||||
|
location: undefined,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
<AttachmentListView attachmentList={state.attachmentList} setAttachmentList={handleSetAttachmentList} />
|
<AttachmentListView attachmentList={state.attachmentList} setAttachmentList={handleSetAttachmentList} />
|
||||||
<RelationListView relationList={referenceRelations} setRelationList={handleSetRelationList} />
|
<RelationListView relationList={referenceRelations} setRelationList={handleSetRelationList} />
|
||||||
<div className="relative w-full flex flex-row justify-between items-center py-1 gap-2" onFocus={(e) => e.stopPropagation()}>
|
<div className="relative w-full flex flex-row justify-between items-center pt-2 gap-2" onFocus={(e) => e.stopPropagation()}>
|
||||||
<div className="flex flex-row justify-start items-center opacity-60 shrink-1">
|
<div className="flex flex-row justify-start items-center gap-1">
|
||||||
<TagSelector editorRef={editorRef} />
|
<InsertMenu
|
||||||
<MarkdownMenu editorRef={editorRef} />
|
isUploading={state.isUploadingAttachment}
|
||||||
<UploadAttachmentButton isUploading={state.isUploadingAttachment} />
|
|
||||||
<AddMemoRelationPopover />
|
|
||||||
<LocationSelector
|
|
||||||
location={state.location}
|
location={state.location}
|
||||||
onChange={(location) =>
|
onLocationChange={(location) =>
|
||||||
setState((prevState) => ({
|
setState((prevState) => ({
|
||||||
...prevState,
|
...prevState,
|
||||||
location,
|
location,
|
||||||
|
|
@ -512,39 +514,15 @@ const MemoEditor = observer((props: Props) => {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="shrink-0 -mr-1 flex flex-row justify-end items-center gap-1">
|
<div className="shrink-0 flex flex-row justify-end items-center gap-1">
|
||||||
|
<VisibilitySelector value={state.memoVisibility} onChange={(visibility) => handleMemoVisibilityChange(visibility)} />
|
||||||
{props.onCancel && (
|
{props.onCancel && (
|
||||||
<Button variant="ghost" className="opacity-60" disabled={state.isRequesting} onClick={handleCancelBtnClick}>
|
<Button variant="ghost" disabled={state.isRequesting} onClick={handleCancelBtnClick}>
|
||||||
{t("common.cancel")}
|
{t("common.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button disabled={!allowSave || state.isRequesting} onClick={handleSaveBtnClick}>
|
<Button disabled={!allowSave || state.isRequesting} onClick={handleSaveBtnClick}>
|
||||||
{t("editor.save")}
|
{state.isRequesting ? <LoaderIcon className="w-4 h-4 animate-spin" /> : t("editor.save")}
|
||||||
{!state.isRequesting ? (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
|
|
||||||
<span className="pointer-events-auto">
|
|
||||||
<VisibilityIcon visibility={state.memoVisibility} className="w-4 h-auto ml-1 text-primary-foreground opacity-80" />
|
|
||||||
</span>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" alignOffset={-12} sideOffset={12} onClick={(e) => e.stopPropagation()}>
|
|
||||||
<DropdownMenuItem onClick={() => handleMemoVisibilityChange(Visibility.PRIVATE)}>
|
|
||||||
<VisibilityIcon visibility={Visibility.PRIVATE} className="w-4 h-4" />
|
|
||||||
{t("memo.visibility.private")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => handleMemoVisibilityChange(Visibility.PROTECTED)}>
|
|
||||||
<VisibilityIcon visibility={Visibility.PROTECTED} className="w-4 h-4" />
|
|
||||||
{t("memo.visibility.protected")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => handleMemoVisibilityChange(Visibility.PUBLIC)}>
|
|
||||||
<VisibilityIcon visibility={Visibility.PUBLIC} className="w-4 h-4" />
|
|
||||||
{t("memo.visibility.public")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
) : (
|
|
||||||
<LoaderIcon className="w-4 h-auto ml-1 animate-spin" />
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue